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