# 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` 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).