Summary
GET /api/towers?source=us fetches every licensed low-VHF TV station (channels 2–6) from the FCC, then throws them all away inside process_and_rank, with no log line and no counter. On a California query that is 38 of 431 parsed licensed TV records; within 80 km of downtown LA it silently loses five stations, two of them ~57 kW ERP at 24.5 km.
The two halves disagree:
backend/clients/fcc.py:26-28 synthesises a centre frequency for channels 2–6 — {2: 57, 3: 63, 4: 69, 5: 79, 6: 85} MHz. These values are correct US channel centres.
backend/config/tower_config.json:51 defines broadcast_bands as FM [87.8, 108.0], VHF [174, 216], UHF [470, 608]. Nothing covers 54–88 MHz.
So classify_band() (backend/services/tower_ranking.py:164) returns None for all five frequencies, and the band is None: continue at backend/services/tower_ranking.py:369-371 discards the row. The station has already cost us the FCC round-trip at that point.
Either the channel table should not be producing frequencies the band table cannot classify, or broadcast_bands is missing a low-VHF entry. Right now they are inconsistent, and the inconsistency is silent.
Reproduction
Self-contained — uses this repo's own parsing and classification, no running service needed:
import asyncio, httpx
from clients import fcc
from services.tower_ranking import classify_band, parse_geom, haversine, BROADCAST_BANDS
print("BROADCAST_BANDS =", {k: [list(r) for r in v] for k, v in BROADCAST_BANDS.items()})
print("ch2-6 synthesised =", {c: fcc._TV_CHANNEL_FREQ[c] for c in range(2, 7)})
print("classify_band on those =",
{fcc._TV_CHANNEL_FREQ[c]: classify_band(fcc._TV_CHANNEL_FREQ[c]) for c in range(2, 7)})
async def go():
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.get(fcc._TV_URL,
params={"list": "4", "state": "CA", "city": "", "chan": "0",
"type": "4", "status": "3"},
headers={"User-Agent": "TowerFinder/1.0"})
return [l for l in r.text.strip().split("\n") if l.startswith("|")]
LAT, LON = 34.0522, -118.2437
kept = dropped = 0
near = []
for line in asyncio.run(go()):
d = fcc._parse_tv_line(line)
if d is None:
continue
if classify_band(d["frequency"]) is None:
dropped += 1
c = parse_geom(d["location"]["geom"])
if c and haversine(LAT, LON, c[0], c[1]) <= 80:
near.append((round(haversine(LAT, LON, c[0], c[1]), 1),
d["callsign"], d["_fcc_channel"], d["frequency"]))
else:
kept += 1
print(f"CA licensed TV rows parsed OK: {kept + dropped} kept: {kept} silently dropped: {dropped}")
for x in sorted(near):
print(" ", x)
Output:
BROADCAST_BANDS = {'FM': [[87.8, 108.0]], 'VHF': [[174, 216]], 'UHF': [[470, 608]]}
ch2-6 synthesised = {2: 57, 3: 63, 4: 69, 5: 79, 6: 85}
classify_band on those = {57: None, 63: None, 69: None, 79: None, 85: None}
CA licensed TV rows parsed OK: 431 kept: 393 silently dropped: 38
dropped stations within 80 km of downtown LA:
(24.4, 'KZNO-LD', 6, 85)
(24.5, 'KBEH', 4, 69)
(24.5, 'KWHY-TV', 4, 69)
(25.5, 'KHIZ-LD', 2, 57)
(42.4, 'KSGA-LD', 3, 63)
A note on where this was observed end-to-end
I confirmed the user-visible effect against a running deployment of offworldlabs/retina-server, which carries a byte-identical backend/clients/fcc.py and an identical broadcast_bands config. A live GET /api/towers?lat=34.0522&lon=-118.2437&radius_km=80&limit=200 there returns 177 towers with band buckets {'VHF': 9, 'UHF': 27, 'FM': 141} — no band below 87.8 MHz, and none of the five stations above. The repro script here reproduces the same drop using this repo's code; I did not have a running instance of this service to query directly, so the end-to-end half of the evidence comes from the sibling repo. The same fix applies to both.
Impact
- Low-VHF stations are attractive passive-radar illuminators (long propagation range), so this is dropping some of the most useful entries rather than marginal ones.
- The loss is invisible: no log, no counter, no field in the response.
count looks plausible, so a caller cannot tell coverage is incomplete.
- The FCC fetch cost is paid for these rows and then discarded.
Suggested direction
Two coherent options — needs a call on whether low-VHF is actually wanted:
- If low-VHF should be served: add a band entry covering 54–88 MHz to
broadcast_bands in backend/config/tower_config.json. Note BAND_PRIORITY and MEASUREMENT_TOLERANCE_MHZ key on band names, so a new label (e.g. VHF-Lo) needs entries in both, and the existing TODO(DAB) comment about band-name overloading in tower_ranking.py is relevant context.
- If it should not: stop synthesising frequencies for channels 2–6 in
backend/clients/fcc.py:26-28 and drop them at parse time, so the intent is explicit and the FCC rows are not carried through the pipeline just to be discarded.
Either way, a debug/info log or a counter when classify_band() rejects a row would have made this visible instead of silent.
Related (not part of this issue)
_parse_tv_line also drops any channel absent from _TV_CHANNEL_FREQ, which stops at 36 (backend/clients/fcc.py:85-87). One licensed CA row on channel 40 is dropped this way. That is defensible post-repack, but it is the same silent-drop pattern and might warrant the same logging.
Summary
GET /api/towers?source=usfetches every licensed low-VHF TV station (channels 2–6) from the FCC, then throws them all away insideprocess_and_rank, with no log line and no counter. On a California query that is 38 of 431 parsed licensed TV records; within 80 km of downtown LA it silently loses five stations, two of them ~57 kW ERP at 24.5 km.The two halves disagree:
backend/clients/fcc.py:26-28synthesises a centre frequency for channels 2–6 —{2: 57, 3: 63, 4: 69, 5: 79, 6: 85}MHz. These values are correct US channel centres.backend/config/tower_config.json:51definesbroadcast_bandsasFM [87.8, 108.0],VHF [174, 216],UHF [470, 608]. Nothing covers 54–88 MHz.So
classify_band()(backend/services/tower_ranking.py:164) returnsNonefor all five frequencies, and theband is None: continueatbackend/services/tower_ranking.py:369-371discards the row. The station has already cost us the FCC round-trip at that point.Either the channel table should not be producing frequencies the band table cannot classify, or
broadcast_bandsis missing a low-VHF entry. Right now they are inconsistent, and the inconsistency is silent.Reproduction
Self-contained — uses this repo's own parsing and classification, no running service needed:
Output:
A note on where this was observed end-to-end
I confirmed the user-visible effect against a running deployment of
offworldlabs/retina-server, which carries a byte-identicalbackend/clients/fcc.pyand an identicalbroadcast_bandsconfig. A liveGET /api/towers?lat=34.0522&lon=-118.2437&radius_km=80&limit=200there returns 177 towers with band buckets{'VHF': 9, 'UHF': 27, 'FM': 141}— no band below 87.8 MHz, and none of the five stations above. The repro script here reproduces the same drop using this repo's code; I did not have a running instance of this service to query directly, so the end-to-end half of the evidence comes from the sibling repo. The same fix applies to both.Impact
countlooks plausible, so a caller cannot tell coverage is incomplete.Suggested direction
Two coherent options — needs a call on whether low-VHF is actually wanted:
broadcast_bandsinbackend/config/tower_config.json. NoteBAND_PRIORITYandMEASUREMENT_TOLERANCE_MHZkey on band names, so a new label (e.g.VHF-Lo) needs entries in both, and the existingTODO(DAB)comment about band-name overloading intower_ranking.pyis relevant context.backend/clients/fcc.py:26-28and drop them at parse time, so the intent is explicit and the FCC rows are not carried through the pipeline just to be discarded.Either way, a debug/info log or a counter when
classify_band()rejects a row would have made this visible instead of silent.Related (not part of this issue)
_parse_tv_linealso drops any channel absent from_TV_CHANNEL_FREQ, which stops at 36 (backend/clients/fcc.py:85-87). One licensed CA row on channel 40 is dropped this way. That is defensible post-repack, but it is the same silent-drop pattern and might warrant the same logging.