Skip to content

FCC TV channels 2-6 are fetched then silently dropped: broadcast_bands has no low-VHF range #16

Description

@jehanazad

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions