Skip to content

Model the direct path with Okumura-Hata and derate towers under the beam - #33

Merged
jehanazad merged 1 commit into
mainfrom
feat/terrestrial-path-loss
Sep 13, 2026
Merged

jehanazad merged 1 commit into
mainfrom
feat/terrestrial-path-loss

Conversation

@jehanazad

@jehanazad jehanazad commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Follows from the home-installation PSD comparison: received_power_dbm was EIRP + 6 dBi − free-space loss, and against measurements it ran 20 to 50 dB high. The part of that gap that is the same for every node has a textbook model; the part that is this installation's whip, polarisation and walls does not, and stays the node's to measure (sweep calibration in #30, feedback rows via #32).

What changes

Two terms on the direct path, both in a new propagation config section (shipped in tower_config.json, documented in the README):

  • Okumura-Hata excess over free space. Mast height, receiver height (rx_height_m, shipped 5 m) and environment (open / suburban / urban, shipped suburban). Never negative, so a tower in plain view is still free space away. FM, masts over 200 m and ranges under 1 km are clamped to the formula's edge rather than refused. model: "free_space" is the old behaviour.
  • Under-beam derating. A broadcast beam is a few degrees tall and tilted about a degree down; 3 km from a 300 m mast is 6° under it. Parabolic main lobe, 12·(angle/beamwidth)², per-band vertical_beamwidth_deg (FM 8°, VHF 4°, UHF 2°), capped at max_underbeam_loss_db (20) for null fill. Height is above ground, so a hilltop mast is derated less than it should be, never more.

A record with no antenna height (common on FCC low-power records, which carry 0) borrows the tallest height within the 200 m channel-sharing radius, so the LD channels on a shared mast get the same derating as the full-power record. Indexed on a 0.01° grid; the radius decides, the grid only narrows the search.

Additive fields per tower: path_loss_db, excess_path_loss_db, underbeam_loss_db. Nothing existing changes shape. Overlays that predate the section get the terrestrial model without a config PUT.

Against the measurements

Live query for the home site, model versus the sheet's tinySA and SDRConnect reference readings (mean over-prediction, dB):

Band Before After
FM 24 3
VHF 44 19
UHF 53 25

Per tower, WGGB-TV at 3.6 km goes from −4.8 to −35.6 dBm (measured −54): 11 dB of Hata excess, 20 dB under the beam. WSHM-LD on the same mast, height 0 on record, now borrows WGGB's 320 m and moves from rank 4 to 12. The TV remainder is consistent with a vertical whip receiving horizontally polarised ATSC indoors, which no site-independent model should try to absorb.

Verification

  • 424 backend tests (43 new in test_propagation.py), ruff check and format clean. The existing ranking tests pass unchanged: Hata is monotonic in distance and its excess is near flat across TV bands, so the orderings they pin still hold.
  • Benchmark, 3000 synthetic records half without a height: process_and_rank 660 ms end to end (unchanged code dominates), the mast index plus 1500 lookups 4 ms.

Not in this PR

🤖 Generated with Claude Code

received_power_dbm was licensed EIRP plus 6 dBi minus free-space loss: what a
receiver in a vacuum would hear. Against a real installation it ran 20 to 50 dB
optimistic, and the part of that which is the same for every node has a
textbook model. Two terms, both in a new `propagation` config section:

- Okumura-Hata loss in excess of free space (never negative) for the mast
  height, the receiver height and an environment knob shipped as suburban.
  Inputs outside the formula's validity are clamped to its edge, so FM and
  tall masts get the edge-of-range figure rather than free space.
- An under-beam derating: a broadcast beam is a few degrees tall and tilted
  down about a degree, so a receiver 3 km from a 300 m mast sits 6 degrees
  under it. Parabolic main lobe per band, capped at 20 dB for null fill.

Low-power FCC records often carry antennaHeight 0 while the full-power station
on the same mast carries the real figure, so a record without a height borrows
the tallest one within the channel-sharing radius. Without that the LD
channels at a shared mast escape the derating the full-power record gets.

Each tower now also carries path_loss_db, excess_path_loss_db and
underbeam_loss_db, so a reader can see why a tower 3 km away is not the
loudest thing on the list. No existing field changes shape.

An overlay written before the section existed gets the terrestrial model, not
free space: a config PUT is not the trigger for the change. `model:
"free_space"` restores the old figure.

Against the home-installation measurements the mean over-prediction fell from
24 to 3 dB on FM, 44 to 19 on VHF and 53 to 25 on UHF; the remainder is that
installation's whip, polarisation and walls, which the node has to measure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 2m 49s —— View job


Reviewing PR #33

Todo list

  • Gather context (PR diff, changed files, repo guidelines)
  • Understand the request (code review)
  • Read tower_ranking.py, tower_config.json, README, test_propagation.py diffs
  • Post findings

Note: I wasn't able to execute pytest/ruff in this sandbox (Bash commands required approval that wasn't available), so this review is static — I couldn't independently verify the "424 tests / ruff clean" claim.


Summary

This is a well-structured, well-tested addition. The Hata formula and derating math check out against the standard textbook equations, the clamping logic is sound, and the test suite is thorough (including the tricky "borrow neighbour's mast height" case). Two things worth a look before merge:

1. model: "free_space" doesn't fully restore the old behaviour, despite what the docs claim

backend/services/tower_ranking.py:539-561 (underbeam_loss) never checks PROPAGATION_MODEL — it only looks at MAX_UNDERBEAM_LOSS_DB. So setting propagation.model: "free_space" alone still applies up to 20 dB of under-beam derating on top of the free-space loss.

That contradicts both the README (README.md:60: "model: "free_space" is the previous behaviour, one line away") and the PR body ("model: "free_space" is the old behaviour"). The PR's own test proves the gap — test_free_space_model_reproduces_the_old_formula (backend/tests/test_propagation.py:153-155) has to set both model="free_space" and max_underbeam_loss_db=0 to actually reproduce eirp + gain - fspl:

def test_free_space_model_reproduces_the_old_formula(self, restore_config):
    _propagation(restore_config, model="free_space", max_underbeam_loss_db=0)
    assert received_power(80, 20, 550) == pytest.approx(...)

If under-beam derating is meant to be an independent knob (a distinct antenna-pattern effect, not part of "path loss"), that's a reasonable design — but the docs currently overstate it as a full revert. Worth either (a) gating underbeam_loss() on PROPAGATION_MODEL != "free_space", or (b) fixing the README/PR wording to say "one line away" really means two settings.

Fix this →

2. path_loss() and underbeam_loss() are each computed twice per tower

In process_and_rank (backend/services/tower_ranking.py:944-948):

pwr = received_power(eirp, dist, freq_val, tx_height, band)   # internally calls path_loss() + underbeam_loss()
if pwr < SENSITIVITY_DBM:
    continue
total_loss, excess_loss = path_loss(dist, freq_val, tx_height)   # recomputes Hata
underbeam = underbeam_loss(dist, tx_height, band)                 # recomputes derating

received_power() (tower_ranking.py:563-577) already computes both internally to derive pwr, then the loop recomputes them a second time just to get the breakdown fields for the response. It's not a correctness bug — and the benchmark numbers in the PR (250 ms / 3000 records) suggest it's not currently a problem — but it doubles the Hata/derating math for every surviving tower for no reason. Simplest fix: have received_power return (power, total_loss, excess_loss, underbeam) (or compute the breakdown once and derive pwr from it), and use that single result in the loop.

Minor

  • underbeam_loss()'s fallback for an unrecognized band (tower_ranking.py:559) uses the hardcoded DEFAULT_VERTICAL_BEAMWIDTH_DEG["VHF"] rather than the currently-configured VERTICAL_BEAMWIDTH_DEG.get("VHF", ...). In practice this path is unreachable from process_and_rank (bands are classified and None is filtered out earlier), so it only matters for direct callers of received_power/underbeam_loss with a custom band that isn't in the config — low impact, but slightly inconsistent with "config always wins."
  • Hata's formal validity range tops out around 20 km, but hata_excess_loss clamps the minimum distance (1 km) without an upper clamp, and the default search radius is 80 km. Excess loss is extrapolated well past the formula's tested range at longer distances. This may be intentional (it's monotonic and the PR doesn't claim accuracy beyond the formula's validity), but it's not mentioned in the README's list of clamped inputs, worth a one-line callout if it's a known tradeoff.

Nice work on the docstrings and the "reason why" comments throughout — they make the physical reasoning easy to follow and cross-check.

@jehanazad
jehanazad merged commit 52d1883 into main Sep 13, 2026
7 checks passed
jehanazad added a commit that referenced this pull request Sep 13, 2026
Reconciles the ranking redesign with what landed on main since it branched:

- The fleet feedback ingest merged separately as #32, with run_id dedupe and
  device_error on top of what this branch carried. Its files are taken from
  main wholesale; this branch keeps the ranking-side wiring (apply_feedback
  before the sort) and the README paragraph on how rows enter the ranking,
  folded into the newer section.
- #33's terrestrial path loss and under-beam derating now shape
  received_power_dbm, which the sweep calibration reads. tower_scoring still
  models its own direct path with free space plus the horizon term, so the two
  direct-path figures differ; pointing scoring at path_loss/underbeam_loss for
  the direct path (and keeping free space for the target echo) is the
  follow-up #33 named.
- Address lookup (#31) and the marker stacking fix (#29) merge cleanly apart
  from adjacent lines in app.py, the test helpers and the README API table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant