import argparse import csv import io import json import os import sys import zipfile from collections import defaultdict from datetime import datetime, timezone from pathlib import Path import h3 sys.path.insert(0, str(Path(__file__).resolve().parent)) from build_fcc import CACHE, DATA, FIPS, USPS, api_download, as_of_dates, dump, log, parse_states, reset_dir from build_bead import availability_files RES = 7 SHARD = 4 TECH = "61" def to_int(v): try: return int(float(v)) except (TypeError, ValueError): return 0 def is_starlink(brand, provider): s = f"{brand} {provider}".lower() return "starlink" in s or "space exploration" in s or "spacex" in s def rows(path): openers = [] if path.suffix.lower() == ".zip": z = zipfile.ZipFile(path) for n in z.namelist(): if n.lower().endswith(".csv"): openers.append(lambda n=n: io.TextIOWrapper(z.open(n), encoding="utf-8-sig", newline="")) else: openers.append(lambda: open(path, encoding="utf-8-sig", newline="")) for o in openers: with o() as fh: r = csv.reader(fh) head = next(r, None) if not head: continue idx = {k: head.index(k) if k in head else None for k in ("brand_name", "provider_id", "technology", "h3_res8_id", "max_advertised_download_speed", "max_advertised_upload_speed")} if idx["h3_res8_id"] is None: continue top = max(i for i in idx.values() if i is not None) g = lambda row, k: row[idx[k]] if idx[k] is not None else "" for row in r: if len(row) <= top: continue if idx["technology"] is not None and g(row, "technology").strip() != TECH: continue yield g(row, "brand_name"), g(row, "provider_id"), g(row, "h3_res8_id"), g(row, "max_advertised_download_speed"), g(row, "max_advertised_upload_speed") def ingest(path, cells): n = 0 for brand, provider, cell, down, up in rows(path): if not is_starlink(brand, provider): continue cell = cell.strip().lower() if not h3.is_valid_cell(cell): continue c7 = h3.cell_to_parent(cell, RES) e = cells.get(c7) d, u = to_int(down), to_int(up) if e is None: cells[c7] = [d, u] else: e[0] = max(e[0], d) e[1] = max(e[1], u) n += 1 return n def write(cells, states, as_of): root = DATA / "starlink" reset_dir(root) shards = defaultdict(dict) for c, v in cells.items(): shards[h3.cell_to_parent(c, SHARD)][c] = v for sid, cs in shards.items(): tiers = sorted({tuple(v) for v in cs.values()}) idx = {t: i for i, t in enumerate(tiers)} dump(root / f"{sid}.json", {"t": [list(t) for t in tiers], "c": {c: idx[tuple(v)] for c, v in cs.items()}}) path = DATA / "manifest.json" m = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} m.update({ "starlinkStates": sorted(states), "starlinkAsOf": as_of, "starlinkRes": RES, "starlinkShardRes": SHARD, "starlinkGenerated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), }) path.write_text(json.dumps(m, indent=2) + "\n", encoding="utf-8") log(f"Starlink: {len(cells)} cells in {len(shards)} shards, states {sorted(states)}") def main(): ap = argparse.ArgumentParser(description="Build Starlink availability tiles from FCC data") ap.add_argument("--states", default=os.environ.get("STATES", "AZ")) ap.add_argument("--local", help="Folder of FCC satellite availability CSV or ZIP files") ap.add_argument("--as-of", default="local data") a = ap.parse_args() states = parse_states(a.states) cells, done = {}, set() if a.local: for p in sorted(Path(a.local).iterdir()): if p.suffix.lower() in (".zip", ".csv"): log(f"{p.name}: {ingest(p, cells)} Starlink rows") done = set(states) as_of = a.as_of else: user = os.environ.get("FCC_USERNAME", "").strip() token = os.environ.get("FCC_API_TOKEN", "").strip() if not user or not token: sys.exit("Set FCC_USERNAME and FCC_API_TOKEN.") wanted = {FIPS[s] for s in states} files, as_of = {}, None for as_of in as_of_dates(user, token)[:4]: files = availability_files(as_of, wanted, user, token) if any(TECH in v for v in files.values()): break CACHE.mkdir(exist_ok=True) for fips in sorted(files): st = USPS[fips] for f in files[fips].get(TECH, []): fid = f.get("file_id") dest = CACHE / f"{as_of}_{fid}.zip" if not dest.exists(): log(f"{st}: downloading {f.get('file_name', fid)}") api_download(f"/downloads/downloadFile/availability/{fid}", user, token, dest) log(f"{st}: {ingest(dest, cells)} Starlink rows, {len(cells)} cells so far") dest.unlink() done.add(st) if not cells: sys.exit("No Starlink rows found. Nothing written.") write(cells, done, as_of) if __name__ == "__main__": main()