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.
99 lines
4.4 KiB
Markdown
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).
|