Add Starlink availability line to results

This commit is contained in:
Justin Oros
2026-10-07 14:50:59 -07:00
parent 50a060bacf
commit 32a8481fc9
4 changed files with 293 additions and 5 deletions
+86
View File
@@ -0,0 +1,86 @@
name: Update fiber data
on:
workflow_dispatch:
inputs:
states:
description: "Comma separated state codes like AZ,NM, or ALL. Blank uses the STATES variable."
required: false
default: ""
schedule:
- cron: "17 9 5 * *"
push:
branches: [main]
paths:
- "data/contracts/**"
permissions:
contents: write
concurrency:
group: update-data
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 340
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install
run: pip install -r scripts/requirements.txt
- name: Build
env:
FCC_USERNAME: ${{ secrets.FCC_USERNAME }}
FCC_API_TOKEN: ${{ secrets.FCC_API_TOKEN }}
STATES: ${{ github.event.inputs.states || vars.STATES || 'AZ' }}
EVENT: ${{ github.event_name }}
run: |
if [ "$EVENT" = "push" ]; then
python scripts/build_fcc.py --manifest-only
else
python scripts/build_fcc.py --states "$STATES"
fi
- name: Build BEAD project areas
if: github.event_name != 'push'
continue-on-error: true
env:
FCC_USERNAME: ${{ secrets.FCC_USERNAME }}
FCC_API_TOKEN: ${{ secrets.FCC_API_TOKEN }}
STATES: ${{ github.event.inputs.states || vars.STATES || 'AZ' }}
run: python scripts/build_bead.py --states "$STATES"
- name: Build Starlink availability
if: github.event_name != 'push'
continue-on-error: true
env:
FCC_USERNAME: ${{ secrets.FCC_USERNAME }}
FCC_API_TOKEN: ${{ secrets.FCC_API_TOKEN }}
STATES: ${{ github.event.inputs.states || vars.STATES || 'AZ' }}
run: python scripts/build_starlink.py --states "$STATES"
- name: Commit
run: |
git config user.name "github-actions[bot]"
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
git add -A data
if git diff --cached --quiet; then
echo "No changes"
exit 0
fi
git commit -m "Update fiber data"
for i in 1 2 3 4 5; do
if git pull --rebase -X theirs origin main && git push origin HEAD:main; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $((i * 10))
done
exit 1
+4
View File
@@ -45,6 +45,10 @@ python scripts/build_fcc.py --local path/to/folder --as-of "June 30, 2025"
Please credit BroadbandExpanded if you reuse this data. Please credit BroadbandExpanded if you reuse this data.
## Starlink
`scripts/build_starlink.py` reads the FCC low Earth orbit satellite availability files (technology code 61), keeps the rows reported by Starlink, and writes the max advertised speeds per H3 resolution 7 cell to `data/starlink/`. The page shows one line in the result saying whether Starlink reports service at the address.
## Adding contracts and bids ## Adding contracts and bids
Edit or add `data/contracts/XX.json` (XX is the state code). Pushing a change refreshes the manifest automatically. Edit or add `data/contracts/XX.json` (XX is the state code). Pushing a change refreshes the manifest automatically.
+52 -5
View File
@@ -21,7 +21,8 @@ const S = {
contracts: new Map(), contracts: new Map(),
beadFine: new Map(), beadFine: new Map(),
beadCoarse: new Map(), beadCoarse: new Map(),
beadProjects: new Map() beadProjects: new Map(),
starlink: new Map()
}; };
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -258,6 +259,48 @@ function shard(cache, dir, id, root = "fiber") {
return cache.get(key); return cache.get(key);
} }
async function starlinkAt(lat, lng) {
const m = S.manifest;
const res = m.starlinkRes || 7;
const sr = m.starlinkShardRes || 4;
const read = async c => {
const id = h3.cellToParent(c, sr);
if (!S.starlink.has(id)) S.starlink.set(id, getJSON(`data/starlink/${id}.json`));
const d = await S.starlink.get(id);
const i = d && d.c[c];
return i == null ? null : d.t[i];
};
const cell = h3.latLngToCell(lat, lng, res);
const own = await read(cell);
if (own) return { here: true, down: own[0], up: own[1] };
const near = (await Promise.all(h3.gridDisk(cell, 1).filter(c => c !== cell).map(read))).filter(Boolean);
if (near.length) {
return { here: false, near: true, down: Math.max(...near.map(v => v[0])), up: Math.max(...near.map(v => v[1])) };
}
return { here: false, near: false };
}
function starlinkBlock(place, sl) {
const b = el("section", "block");
b.append(el("h3", null, "Starlink"));
const states = S.manifest.starlinkStates || [];
let text;
if (!states.includes(place.state) || !sl) text = "No Starlink data for this state yet.";
else if (sl.here) text = `Starlink reports service here, up to ${fmtSpeed(sl.down)} down and ${fmtSpeed(sl.up)} up.`;
else if (sl.near) text = `Starlink reports service close by, up to ${fmtSpeed(sl.down)} down and ${fmtSpeed(sl.up)} up.`;
else text = "Starlink does not report service here.";
b.append(el("p", null, text));
const n = el("p", "note");
n.append("Starlink can waitlist areas that are at capacity, so confirm on ");
const a = el("a", null, "starlink.com");
a.href = "https://www.starlink.com/map";
a.target = "_blank";
a.rel = "noopener";
n.append(a, " before ordering.");
b.append(n);
return b;
}
async function beadNear(lat, lng) { async function beadNear(lat, lng) {
const m = S.manifest; const m = S.manifest;
const cell = h3.latLngToCell(lat, lng, m.fineRes); const cell = h3.latLngToCell(lat, lng, m.fineRes);
@@ -629,7 +672,7 @@ function statewideList(items) {
return wrap; return wrap;
} }
function render(place, covered, at, region, deals, bead) { function render(place, covered, at, region, deals, bead, sl) {
const root = $("result"); const root = $("result");
root.replaceChildren(); root.replaceChildren();
@@ -645,6 +688,8 @@ function render(place, covered, at, region, deals, bead) {
root.append(b); root.append(b);
} }
if ((S.manifest.starlinkStates || []).length) root.append(starlinkBlock(place, sl));
if (covered) { if (covered) {
const b = el("section", "block"); const b = el("section", "block");
b.append(el("h3", null, "Fiber within 50 miles")); b.append(el("h3", null, "Fiber within 50 miles"));
@@ -733,11 +778,13 @@ async function check(q) {
} }
const covered = S.manifest.states.includes(place.state); const covered = S.manifest.states.includes(place.state);
const hasBead = (S.manifest.beadStates || []).length > 0; const hasBead = (S.manifest.beadStates || []).length > 0;
const [at, region, deals, bead] = await Promise.all([ const hasStarlink = (S.manifest.starlinkStates || []).includes(place.state);
const [at, region, deals, bead, sl] = await Promise.all([
covered ? fiberAt(place.lat, place.lng) : null, covered ? fiberAt(place.lat, place.lng) : null,
covered ? fiberRegion(place.lat, place.lng) : null, covered ? fiberRegion(place.lat, place.lng) : null,
contractsNear(place), contractsNear(place),
hasBead ? beadNear(place.lat, place.lng).catch(() => null) : null hasBead ? beadNear(place.lat, place.lng).catch(() => null) : null,
hasStarlink ? starlinkAt(place.lat, place.lng).catch(() => null) : null
]); ]);
if (bead && (S.manifest.beadStates || []).includes(place.state)) { if (bead && (S.manifest.beadStates || []).includes(place.state)) {
deals.items = deals.items.filter(it => it.program !== "BEAD"); deals.items = deals.items.filter(it => it.program !== "BEAD");
@@ -747,7 +794,7 @@ async function check(q) {
drawContracts(deals.items); drawContracts(deals.items);
drawFocus(place, at); drawFocus(place, at);
S.map.fitBounds(L.latLng(place.lat, place.lng).toBounds(RADIUS_KM * 2000), { padding: [10, 10] }); S.map.fitBounds(L.latLng(place.lat, place.lng).toBounds(RADIUS_KM * 2000), { padding: [10, 10] });
render(place, covered, at, region, deals, bead); render(place, covered, at, region, deals, bead, sl);
const url = new URL(location.href); const url = new URL(location.href);
url.searchParams.set("q", q); url.searchParams.set("q", q);
history.replaceState(null, "", url); history.replaceState(null, "", url);
+151
View File
@@ -0,0 +1,151 @@
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()