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.
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 ≤ bandget 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 tonil→ 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/1with a native-duct row returns the minmin_freq_ghz; with no ducts returnsnil; with mixed shapes prefers the computable one.WeatherTileControllerTest— binary pack includes the new field when requested via?layers=duct_cutoff_ghz.MapLayersTest—valid_id?/1accepts"duct_cutoff_ghz".
Rust:
weather_scalar_file::derive_rowtest passes aCellValueswithbest_duct_freq_ghz: 18.4and 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).