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

4.4 KiB

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:

  • WeatherLayersTestderive/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.
  • MapLayersTestvalid_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).