prop/docs/plans/2026-05-15-duct-cutoff-weather-layer.md
Graham McIntire e3d430f8c4
feat(weather): add duct cutoff band map layer
Show on /weather where the detected duct geometry traps each microwave
band. Overview mode bins cells by their lowest trapped frequency
(Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't
reach the selected band. Cutoff is pre-computed per cell in both
writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from
CellValues), persisted to ScalarFile, and shipped to the JS hook via
the existing binary cell pack. Also cleans up six pre-existing
length/1 credo warnings in unrelated test files.
2026-05-15 19:51:24 -05:00

99 lines
4.4 KiB
Markdown

# Duct Cutoff Weather Layer
Add a `/weather` overlay that colours each grid cell by the lowest microwave-band frequency that its currently-detected duct(s) can trap. Operators can see at a glance where the duct geometry is strong enough to support 10 / 24 / 47 / 76 / 122 / 241 GHz operation.
Physics: the Bean & Dutton waveguide approximation
`f_min = c / (2.5·d·√(ΔM·1e-6))` is already implemented in
both `Microwaveprop.Propagation.Duct.min_trapped_frequency_ghz/1`
(Elixir) and `prop_grid_rs::duct::min_trapped_frequency_ghz` (Rust).
Per-duct `min_freq_ghz` is already pre-computed at HRRR ingestion.
## What ships
A new map layer in the existing `/weather` picker, group `"Ducting"`,
id `"duct_cutoff_ghz"`. Two render modes inside one layer:
- **Overview**: continuous bin colouring keyed on canonical microwave
bands (3.4 / 5.7 / 10 / 24 / 47 / 76 / 122 / 241 GHz). Cells with no
duct render transparent.
- **Band picker**: user selects one band; cells where
`cutoff_ghz ≤ band` get a single solid fill, else transparent.
Click readout: `"Best duct cutoff: 18.4 GHz · 2 ducts · thickest 84 m"`.
## Data plumbing
Per cell we want a single scalar `duct_cutoff_ghz` = min cutoff across
all detected ducts. Both writers must emit it; the wire format is
unchanged otherwise.
**Elixir derive path** (`Microwaveprop.Weather.WeatherLayers.derive/1`):
Iterate `row.duct_characteristics`. Read `min_freq_ghz` (atom or string)
from each entry; fall back to `Duct.min_trapped_frequency_ghz/1` if
only `thickness_m` + `m_deficit` are present; skip sounding-shape
entries that have neither. Take the minimum, return `nil` if none.
**Rust derive path** (`prop_grid_rs::weather_scalar_file::derive_row`):
Add `duct_cutoff_ghz: Option<f64>` to `ScalarRow`. Read `cell.get
("best_duct_freq_ghz")``native_duct::reduce_grid_to_ducts` already
stashes it. No new computation needed.
**ScalarFile @atom_keys**: add `"duct_cutoff_ghz"` so Elixir reads
deserialise into the atom-keyed shape that callers expect.
**WeatherTileController @cell_fields**: add `:duct_cutoff_ghz` to the
binary cell-pack whitelist. The pack format itself doesn't need
changes — it's already a generic per-field f32 array list.
**MapLayers**: register the new entry alongside `duct_strength`.
## UI
`WeatherMapLive` gets one extra assign, `:band_filter` (default `nil` =
overview mode, otherwise an integer GHz value from the band buttons).
When the selected layer is `"duct_cutoff_ghz"`, render an inline strip
of band buttons (`[All] [10] [24] [47] [76] [122] [241]`) below the
layer picker. Clicking a band sends `select_band_filter`, which updates
the assign and pushes `set_band_filter` to the JS hook. Clicking the
active band again resets to `nil`. The band filter state lives in
URL params (`?band=24`) so deep links work.
JS hook (`WeatherMap`): add a colour mapper branch keyed on layer id
`"duct_cutoff_ghz"`. In overview mode, bin the value through the
canonical band thresholds; in band-picker mode, render a single fill
colour where `value ≤ selected_band` (NaN → transparent). The
existing point-detail readout uses the same value field.
## Edge cases
- Old ScalarFiles without `duct_cutoff_ghz`: field decodes to `nil`
NaN on the wire → transparent. Resolves naturally as new hourly
scalar files are written.
- Cells with no ducts: `duct_cutoff_ghz: nil` → transparent (correct).
- Sounding-shape ducts (`%{"base", "strength"}` without
thickness/M-deficit): skipped in the Elixir derive. They don't
appear on a gridded HRRR map.
- Cutoff above the highest band (e.g. 800 GHz duct only): still binned
to the top band (≥ 241 GHz) so it doesn't fall off the colour scale.
## Test plan
**Elixir**:
- `WeatherLayersTest``derive/1` with a native-duct row returns the
min `min_freq_ghz`; with no ducts returns `nil`; with mixed
shapes prefers the computable one.
- `WeatherTileControllerTest` — binary pack includes the new field
when requested via `?layers=duct_cutoff_ghz`.
- `MapLayersTest``valid_id?/1` accepts `"duct_cutoff_ghz"`.
**Rust**:
- `weather_scalar_file::derive_row` test passes a `CellValues` with
`best_duct_freq_ghz: 18.4` and asserts the field round-trips.
**LiveView**:
- `WeatherMapLiveTest` — selecting the new layer renders the band
buttons; clicking 24 updates the assign and pushes the event.
Out of scope for v1: HRDPS band cutoff layer (same plumbing applies,
add later), historical backfill of the new field into old
ScalarFiles (let hourly writes refresh).