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.
This commit is contained in:
Graham McIntire 2026-05-15 19:51:24 -05:00
parent 43239dbb15
commit e3d430f8c4
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
14 changed files with 487 additions and 26 deletions

View file

@ -30,7 +30,18 @@ interface BooleanColorScale {
unit: string unit: string
} }
type ColorScale = ContinuousColorScale | BooleanColorScale // Discrete band-step scale: each step is (upper-bound GHz, RGB). The
// first step whose `upTo` >= value wins. Used by the duct-cutoff
// layer so cells snap to amateur-band bins instead of interpolating.
interface BandStepsColorScale {
breakpoints: "band_steps"
steps: Array<{ upTo: number } & RGB>
format: (v: number | null) => string
label: string
unit: string
}
type ColorScale = ContinuousColorScale | BooleanColorScale | BandStepsColorScale
type LayerId = keyof typeof COLOR_SCALES type LayerId = keyof typeof COLOR_SCALES
@ -62,6 +73,7 @@ interface WeatherPoint {
ducting: boolean | null ducting: boolean | null
duct_base_m: number | null duct_base_m: number | null
duct_strength: number | null duct_strength: number | null
duct_cutoff_ghz: number | null
[key: string]: unknown [key: string]: unknown
} }
@ -91,6 +103,7 @@ interface WeatherMapHook extends ViewHook {
// otherwise ["hrrr", "hrdps"] so the main map paints both grids. // otherwise ["hrrr", "hrdps"] so the main map paints both grids.
sources: SourceName[] sources: SourceName[]
selectedLayer: LayerId selectedLayer: LayerId
bandFilter: number | null
packs: Map<SourceName, CellPack> packs: Map<SourceName, CellPack>
packKey: string | null packKey: string | null
inflightAborts: Map<SourceName, AbortController> inflightAborts: Map<SourceName, AbortController>
@ -141,6 +154,12 @@ function isMobile(): boolean {
return window.innerWidth < 768 return window.innerWidth < 768
} }
function parseBandFilter(raw: string | undefined): number | null {
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
}
const COLOR_SCALES = { const COLOR_SCALES = {
refractivity_gradient: { refractivity_gradient: {
breakpoints: [ breakpoints: [
@ -360,6 +379,27 @@ const COLOR_SCALES = {
format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A", format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A",
label: "Duct Strength", label: "Duct Strength",
unit: "M" unit: "M"
},
// Lowest band the duct supports. A low cutoff means even the
// popular lower bands work; a high cutoff means the duct is so thin
// only millimeter-wave gets trapped. Greener = broader operational
// utility.
duct_cutoff_ghz: {
breakpoints: "band_steps",
steps: [
{ upTo: 3.4, r: 0, g: 200, b: 130 },
{ upTo: 5.7, r: 0, g: 255, b: 163 },
{ upTo: 10, r: 125, g: 255, b: 212 },
{ upTo: 24, r: 255, g: 229, b: 102 },
{ upTo: 47, r: 255, g: 180, b: 80 },
{ upTo: 76, r: 255, g: 144, b: 68 },
{ upTo: 122, r: 255, g: 100, b: 80 },
{ upTo: 241, r: 255, g: 79, b: 79 },
{ upTo: Infinity, r: 200, g: 50, b: 120 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} GHz` : "No duct",
label: "Duct Cutoff Band",
unit: "GHz"
} }
} as const satisfies Record<string, ColorScale> } as const satisfies Record<string, ColorScale>
@ -477,13 +517,31 @@ interface BoolPaintScale {
trueColor: string trueColor: string
} }
function buildPaintScale(layerId: LayerId): PaintScale | BoolPaintScale | null { // Discrete steps with an optional ≤ threshold filter ("band picker
// mode"). When `mask` is set, only paint cells whose value is ≤ mask;
// when null, paint all cells using their step color.
interface BandStepsPaintScale {
kind: "band_steps"
steps: Array<{ upTo: number; color: string }>
mask: number | null
}
type AnyPaintScale = PaintScale | BoolPaintScale | BandStepsPaintScale
function buildPaintScale(layerId: LayerId, bandFilter: number | null): AnyPaintScale | null {
const scale = COLOR_SCALES[layerId] const scale = COLOR_SCALES[layerId]
if (!scale) return null if (!scale) return null
if (scale.breakpoints === null) { if (scale.breakpoints === null) {
const c = scale.trueColor const c = scale.trueColor
return { kind: "bool", trueColor: `rgb(${c.r},${c.g},${c.b})` } return { kind: "bool", trueColor: `rgb(${c.r},${c.g},${c.b})` }
} }
if (scale.breakpoints === "band_steps") {
return {
kind: "band_steps",
steps: scale.steps.map(s => ({ upTo: s.upTo, color: `rgb(${s.r},${s.g},${s.b})` })),
mask: bandFilter
}
}
return { kind: "continuous", ...buildContinuousLut(scale.breakpoints) } return { kind: "continuous", ...buildContinuousLut(scale.breakpoints) }
} }
@ -500,6 +558,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
onRemove: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay onRemove: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay
setPack: (this: WeatherCanvasOverlay, pack: CellPack | null) => void setPack: (this: WeatherCanvasOverlay, pack: CellPack | null) => void
setLayer: (this: WeatherCanvasOverlay, layerId: LayerId) => void setLayer: (this: WeatherCanvasOverlay, layerId: LayerId) => void
setBandFilter: (this: WeatherCanvasOverlay, band: number | null) => void
_reset: (this: WeatherCanvasOverlay) => void _reset: (this: WeatherCanvasOverlay) => void
_draw: (this: WeatherCanvasOverlay) => void _draw: (this: WeatherCanvasOverlay) => void
} = { } = {
@ -507,6 +566,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
this.pack = pack this.pack = pack
this.layerId = layerId this.layerId = layerId
this.halfStep = halfStep this.halfStep = halfStep
this.bandFilter = null
}, },
onAdd(map: L.Map): WeatherCanvasOverlay { onAdd(map: L.Map): WeatherCanvasOverlay {
this._map = map this._map = map
@ -534,6 +594,10 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
this.layerId = layerId this.layerId = layerId
this._reset() this._reset()
}, },
setBandFilter(band: number | null): void {
this.bandFilter = band
this._reset()
},
_reset(): void { _reset(): void {
if (!this._map) return if (!this._map) return
const map = this._map const map = this._map
@ -554,7 +618,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
const values = this.pack.layers.get(this.layerId) const values = this.pack.layers.get(this.layerId)
if (!values) return if (!values) return
const paintScale = buildPaintScale(this.layerId) const paintScale = buildPaintScale(this.layerId, this.bandFilter)
if (!paintScale) return if (!paintScale) return
const map = this._map const map = this._map
@ -576,6 +640,15 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
if (paintScale.kind === "bool") { if (paintScale.kind === "bool") {
if (v < 0.5) continue if (v < 0.5) continue
color = paintScale.trueColor color = paintScale.trueColor
} else if (paintScale.kind === "band_steps") {
// Band-picker mode: only paint cells whose cutoff is at or
// below the selected band — that's the operational "this band
// works here" mask. Overview mode (mask null) paints every
// cell by its step color.
if (paintScale.mask != null && v > paintScale.mask) continue
const step = paintScale.steps.find(s => v <= s.upTo)
if (!step) continue
color = step.color
} else { } else {
const t = (v - paintScale.lo) / (paintScale.hi - paintScale.lo) const t = (v - paintScale.lo) / (paintScale.hi - paintScale.lo)
const idx = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255) const idx = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255)
@ -600,10 +673,12 @@ interface WeatherCanvasOverlay extends L.Layer {
pack: CellPack | null pack: CellPack | null
layerId: LayerId layerId: LayerId
halfStep: number halfStep: number
bandFilter: number | null
_map: L.Map _map: L.Map
_canvas: HTMLCanvasElement _canvas: HTMLCanvasElement
setPack(pack: CellPack | null): void setPack(pack: CellPack | null): void
setLayer(layerId: LayerId): void setLayer(layerId: LayerId): void
setBandFilter(band: number | null): void
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -633,7 +708,8 @@ function buildDetailHTML(point: WeatherPoint): string {
{ separator: true }, { separator: true },
{ label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" }, { label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" },
{ label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" }, { label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" },
{ label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" } { label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" },
{ label: "Duct Cutoff", value: COLOR_SCALES.duct_cutoff_ghz.format(point.duct_cutoff_ghz), color: point.duct_cutoff_ghz != null ? "#00ffa3" : "#666" }
] ]
const mobile = isMobile() const mobile = isMobile()
@ -706,6 +782,7 @@ export const WeatherMap: WeatherMapHook = {
// CONUS-only viewport gets an empty HRDPS pack and vice versa. // CONUS-only viewport gets an empty HRDPS pack and vice versa.
this.sources = this.source === "hrdps" ? ["hrdps"] : ["hrrr", "hrdps"] this.sources = this.source === "hrdps" ? ["hrdps"] : ["hrrr", "hrdps"]
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
this.bandFilter = parseBandFilter(this.el.dataset.bandFilter)
this.packs = new Map() this.packs = new Map()
this.packKey = null this.packKey = null
this.inflightAborts = new Map() this.inflightAborts = new Map()
@ -731,6 +808,13 @@ export const WeatherMap: WeatherMapHook = {
this.fetchAndPaintCells({ force: true }) this.fetchAndPaintCells({ force: true })
}) })
this.handleEvent("set_band_filter", ({ band }: { band: number | null }) => {
this.bandFilter = (typeof band === "number" && Number.isFinite(band)) ? band : null
for (const overlay of this.weatherOverlays.values()) {
overlay.setBandFilter(this.bandFilter)
}
})
this.detailPanel = document.getElementById("weather-detail-panel") this.detailPanel = document.getElementById("weather-detail-panel")
if (this.detailPanel) { if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel) L.DomEvent.disableClickPropagation(this.detailPanel)
@ -875,16 +959,31 @@ export const WeatherMap: WeatherMapHook = {
document.addEventListener("visibilitychange", this.visibilityHandler) document.addEventListener("visibilitychange", this.visibilityHandler)
this._layerObserver = new MutationObserver(() => { this._layerObserver = new MutationObserver(() => {
let changed = false
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
if (newLayer && newLayer !== this.selectedLayer) { if (newLayer && newLayer !== this.selectedLayer) {
this.selectedLayer = newLayer this.selectedLayer = newLayer
localStorage.setItem("weatherMap.selectedLayer", newLayer) localStorage.setItem("weatherMap.selectedLayer", newLayer)
// Layer switch: re-color the cells we already have. No network. changed = true
}
const newBand = parseBandFilter(this.el.dataset.bandFilter)
if (newBand !== this.bandFilter) {
this.bandFilter = newBand
for (const overlay of this.weatherOverlays.values()) {
overlay.bandFilter = newBand
}
changed = true
}
if (changed) {
// Layer or band switch: re-color the cells we already have. No network.
this.paintOverlay() this.paintOverlay()
this.refreshLegend() this.refreshLegend()
} }
}) })
this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] }) this._layerObserver.observe(this.el, {
attributes: true,
attributeFilter: ["data-selected-layer", "data-band-filter"]
})
// Mount the legend control once and update its content in place. // Mount the legend control once and update its content in place.
// Removing + re-adding the control on every renderLayer was a // Removing + re-adding the control on every renderLayer was a
@ -1106,10 +1205,12 @@ export const WeatherMap: WeatherMapHook = {
if (!overlay) { if (!overlay) {
const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src)) const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src))
newOverlay.bandFilter = this.bandFilter
newOverlay.addTo(this.map) newOverlay.addTo(this.map)
this.weatherOverlays.set(src, newOverlay) this.weatherOverlays.set(src, newOverlay)
} else { } else {
overlay.pack = pack overlay.pack = pack
overlay.bandFilter = this.bandFilter
overlay.setLayer(this.selectedLayer) overlay.setLayer(this.selectedLayer)
} }
} }
@ -1136,6 +1237,28 @@ export const WeatherMap: WeatherMapHook = {
`<span style="background:transparent;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>None</div>` `<span style="background:transparent;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>None</div>`
} }
if (scale.breakpoints === "band_steps") {
const steps = (scale as BandStepsColorScale).steps
const heading = this.bandFilter != null
? `${scale.label} &middot; ≤ ${this.bandFilter} GHz`
: scale.label
const rowFor = (s: typeof steps[number], i: number) => {
const prev = i > 0 ? steps[i - 1].upTo : 0
const label = !Number.isFinite(s.upTo)
? `&gt; ${prev} GHz`
: prev === 0
? `${s.upTo} GHz`
: `${s.upTo} GHz`
const dim = this.bandFilter != null && prev > this.bandFilter ? "opacity:0.3;" : ""
return `<span style="${dim}"><span style="background:rgb(${s.r},${s.g},${s.b});width:${mobile ? '10px' : '14px'};height:${mobile ? '10px' : '14px'};display:inline-block;border-radius:50%;vertical-align:middle;margin-right:${mobile ? '3px' : '6px'};border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${label}</span></span>`
}
const wrapStyle = mobile
? "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;"
: "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
const header = mobile ? "" : `<strong style="color:#333;">${heading}</strong><br>`
return `<div style="${wrapStyle}">${header}${steps.map(rowFor).join("<br>")}</div>`
}
const bp = (scale as ContinuousColorScale).breakpoints const bp = (scale as ContinuousColorScale).breakpoints
if (mobile) { if (mobile) {

View file

@ -0,0 +1,99 @@
# 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).

View file

@ -144,6 +144,14 @@ defmodule Microwaveprop.Weather.MapLayers do
group: "Ducting", group: "Ducting",
desc: desc:
"Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies." "Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies."
},
%{
id: "duct_cutoff_ghz",
label: "Duct Cutoff Band",
unit: "GHz",
group: "Ducting",
desc:
"Lowest microwave frequency the detected duct geometry can trap (Bean & Dutton waveguide cutoff). In Overview mode, cells are coloured by the highest amateur band the duct supports. Pick a band to mask cells that only support that band or lower."
} }
] ]

View file

@ -75,7 +75,8 @@ defmodule Microwaveprop.Weather.ScalarFile do
"inversion_base_m", "inversion_base_m",
"ducting", "ducting",
"duct_base_m", "duct_base_m",
"duct_strength" "duct_strength",
"duct_cutoff_ghz"
]) ])
@type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => term()} @type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => term()}

View file

@ -1,6 +1,7 @@
defmodule Microwaveprop.Weather.WeatherLayers do defmodule Microwaveprop.Weather.WeatherLayers do
@moduledoc "Derives scalar weather map layers from HRRR profile data." @moduledoc "Derives scalar weather map layers from HRRR profile data."
alias Microwaveprop.Propagation.Duct
alias Microwaveprop.Weather.SoundingParams alias Microwaveprop.Weather.SoundingParams
@doc """ @doc """
@ -30,10 +31,49 @@ defmodule Microwaveprop.Weather.WeatherLayers do
inversion_strength: inversion_strength(sorted), inversion_strength: inversion_strength(sorted),
inversion_base_m: inversion_base_m(sorted), inversion_base_m: inversion_base_m(sorted),
duct_base_m: duct_field(row.duct_characteristics, "base"), duct_base_m: duct_field(row.duct_characteristics, "base"),
duct_strength: duct_field(row.duct_characteristics, "strength") duct_strength: duct_field(row.duct_characteristics, "strength"),
duct_cutoff_ghz: duct_cutoff_ghz(row.duct_characteristics)
} }
end end
# Lowest microwave frequency that any detected duct can trap — the
# "best operationally useful band" for the cell. Prefers the
# pre-computed `:min_freq_ghz` on native-shape ducts (string or atom
# key); falls back to `Duct.min_trapped_frequency_ghz/1` when only
# `thickness_m` + `m_deficit` are present; skips sounding-shape ducts
# that carry neither (these don't appear on the HRRR weather grid in
# practice but we tolerate them for safety).
defp duct_cutoff_ghz(nil), do: nil
defp duct_cutoff_ghz([]), do: nil
defp duct_cutoff_ghz(ducts) when is_list(ducts) do
ducts
|> Enum.map(&duct_min_freq/1)
|> Enum.reject(&is_nil/1)
|> Enum.min(fn -> nil end)
end
defp duct_min_freq(duct) when is_map(duct) do
case duct[:min_freq_ghz] || duct["min_freq_ghz"] do
f when is_number(f) and f > 0 -> f * 1.0
_ -> duct_min_freq_from_geometry(duct)
end
end
defp duct_min_freq(_), do: nil
defp duct_min_freq_from_geometry(duct) do
thickness = duct[:thickness_m] || duct["thickness_m"]
deficit = duct[:m_deficit] || duct["m_deficit"]
if valid_geometry?(thickness, deficit) do
Duct.min_trapped_frequency_ghz(%{thickness_m: thickness * 1.0, m_deficit: deficit * 1.0})
end
end
defp valid_geometry?(t, d) when is_number(t) and is_number(d) and t > 0 and d > 0, do: true
defp valid_geometry?(_, _), do: false
defp sort_profile(nil), do: [] defp sort_profile(nil), do: []
defp sort_profile([]), do: [] defp sort_profile([]), do: []

View file

@ -14,7 +14,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
surface_refractivity refractivity_gradient bl_height pwat surface_refractivity refractivity_gradient bl_height pwat
temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
lapse_rate mid_lapse_rate inversion_strength inversion_base_m lapse_rate mid_lapse_rate inversion_strength inversion_base_m
ducting duct_base_m duct_strength ducting duct_base_m duct_strength duct_cutoff_ghz
)a )a
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t() @spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()

View file

@ -8,6 +8,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@layers MapLayers.all() @layers MapLayers.all()
@default_layer MapLayers.default_id() @default_layer MapLayers.default_id()
# Canonical amateur microwave bands for the duct-cutoff layer picker.
# Buttons render in this order; an "All" reset clears the filter.
@cutoff_bands [10, 24, 47, 76, 122, 241]
# Default map viewport. Used when no `lat`/`lon`/`zoom` URL params are # Default map viewport. Used when no `lat`/`lon`/`zoom` URL params are
# provided (the deep-link case). Centered on DFW (the project's home # provided (the deep-link case). Centered on DFW (the project's home
# area) at z=7 — same defaults the JS hook used to hardcode. # area) at z=7 — same defaults the JS hook used to hardcode.
@ -46,6 +50,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
page_title: "Weather Map", page_title: "Weather Map",
layers: @layers, layers: @layers,
selected_layer: @default_layer, selected_layer: @default_layer,
cutoff_bands: @cutoff_bands,
band_filter: nil,
valid_time: initial_vt, valid_time: initial_vt,
valid_times: valid_times, valid_times: valid_times,
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)), initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
@ -65,6 +71,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@impl true @impl true
def handle_params(params, _uri, socket) do def handle_params(params, _uri, socket) do
selected = pick_layer(params["layer"], socket.assigns[:selected_layer]) selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
band = pick_band(params["band"], selected)
# Update current viewport so subsequent push_patch URLs combine # Update current viewport so subsequent push_patch URLs combine
# `layer=` with whatever lat/lon/zoom the user has navigated to. # `layer=` with whatever lat/lon/zoom the user has navigated to.
@ -75,6 +82,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
{:noreply, {:noreply,
socket socket
|> assign(:selected_layer, selected) |> assign(:selected_layer, selected)
|> assign(:band_filter, band)
|> assign(:current_lat, lat) |> assign(:current_lat, lat)
|> assign(:current_lon, lon) |> assign(:current_lon, lon)
|> assign(:current_zoom, zoom)} |> assign(:current_zoom, zoom)}
@ -87,6 +95,18 @@ defmodule MicrowavepropWeb.WeatherMapLive do
defp pick_layer(_layer, current) when is_binary(current), do: current defp pick_layer(_layer, current) when is_binary(current), do: current
defp pick_layer(_layer, _current), do: @default_layer defp pick_layer(_layer, _current), do: @default_layer
# Band filter only applies to the duct-cutoff layer; switching away
# from it drops the filter so a stale `?band=24` on the URL doesn't
# ghost-mask the next layer that lands on the same hook.
defp pick_band(band, "duct_cutoff_ghz") when is_binary(band) do
case Integer.parse(band) do
{n, ""} -> if n in @cutoff_bands, do: n
_ -> nil
end
end
defp pick_band(_band, _layer), do: nil
# Parse `lat` / `lon` / `zoom` query params, falling back to defaults # Parse `lat` / `lon` / `zoom` query params, falling back to defaults
# when missing or out of range. Lat in [-90, 90], lon in [-180, 180], # when missing or out of range. Lat in [-90, 90], lon in [-180, 180],
# zoom in [@min_zoom, @max_zoom]. A bad single param doesn't poison # zoom in [@min_zoom, @max_zoom]. A bad single param doesn't poison
@ -148,6 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
# `select_layer`) tweak one field without losing the others. # `select_layer`) tweak one field without losing the others.
defp build_path(socket, overrides \\ []) do defp build_path(socket, overrides \\ []) do
layer = Keyword.get(overrides, :layer, socket.assigns.selected_layer) layer = Keyword.get(overrides, :layer, socket.assigns.selected_layer)
band = Keyword.get(overrides, :band, socket.assigns[:band_filter])
lat = Keyword.get(overrides, :lat, socket.assigns.current_lat) lat = Keyword.get(overrides, :lat, socket.assigns.current_lat)
lon = Keyword.get(overrides, :lon, socket.assigns.current_lon) lon = Keyword.get(overrides, :lon, socket.assigns.current_lon)
zoom = Keyword.get(overrides, :zoom, socket.assigns.current_zoom) zoom = Keyword.get(overrides, :zoom, socket.assigns.current_zoom)
@ -155,13 +176,11 @@ defmodule MicrowavepropWeb.WeatherMapLive do
# List form (not map) so the param order is stable — tests # List form (not map) so the param order is stable — tests
# asserting on the exact URL, and humans reading shareable links, # asserting on the exact URL, and humans reading shareable links,
# both benefit from layer first then viewport. # both benefit from layer first then viewport.
base = [{"layer", layer}]
band_params = if layer == "duct_cutoff_ghz" and is_integer(band), do: [{"band", band}], else: []
query = query =
URI.encode_query([ URI.encode_query(base ++ band_params ++ [{"lat", lat}, {"lon", lon}, {"zoom", zoom}])
{"layer", layer},
{"lat", lat},
{"lon", lon},
{"zoom", zoom}
])
"/weather?#{query}" "/weather?#{query}"
end end
@ -169,12 +188,42 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@impl true @impl true
def handle_event("select_layer", %{"layer" => layer_id}, socket) do def handle_event("select_layer", %{"layer" => layer_id}, socket) do
if MapLayers.valid_id?(layer_id) do if MapLayers.valid_id?(layer_id) do
{:noreply, push_patch(socket, to: build_path(socket, layer: layer_id))} # Drop a lingering band filter when switching to a non-cutoff
# layer — keeps the URL tidy and avoids ghost-mask state.
band = if layer_id == "duct_cutoff_ghz", do: socket.assigns[:band_filter]
{:noreply, push_patch(socket, to: build_path(socket, layer: layer_id, band: band))}
else else
{:noreply, socket} {:noreply, socket}
end end
end end
# Click the active band again to reset to "All" (continuous overview).
def handle_event("select_band_filter", %{"band" => band_str}, socket) do
band =
case Integer.parse(band_str || "") do
{n, ""} -> if n in @cutoff_bands, do: n
_ -> nil
end
next = if band == socket.assigns[:band_filter], do: nil, else: band
socket =
socket
|> assign(:band_filter, next)
|> push_event("set_band_filter", %{band: next})
{:noreply, push_patch(socket, to: build_path(socket, band: next))}
end
def handle_event("clear_band_filter", _params, socket) do
socket =
socket
|> assign(:band_filter, nil)
|> push_event("set_band_filter", %{band: nil})
{:noreply, push_patch(socket, to: build_path(socket, band: nil))}
end
# Debounced moveend/zoomend from the JS hook. Every viewport change # Debounced moveend/zoomend from the JS hook. Every viewport change
# patches the URL so a refresh / share-link lands the user back at # patches the URL so a refresh / share-link lands the user back at
# the same view. Stays out of the LiveView assigns hot path — # the same view. Stays out of the LiveView assigns hot path —
@ -328,6 +377,41 @@ defmodule MicrowavepropWeb.WeatherMapLive do
end end
end end
attr :bands, :list, required: true
attr :selected, :any, default: nil
defp cutoff_band_picker(assigns) do
~H"""
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
Filter to band
</div>
<div class="flex flex-wrap gap-1">
<button
phx-click="clear_band_filter"
class={[
"btn btn-xs rounded-full",
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
]}
>
All
</button>
<button
:for={band <- @bands}
phx-click="select_band_filter"
phx-value-band={band}
class={[
"btn btn-xs rounded-full font-mono tabular-nums",
if(@selected == band, do: "btn-primary", else: "btn-ghost")
]}
>
{band}G
</button>
</div>
</div>
"""
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -341,6 +425,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
phx-update="ignore" phx-update="ignore"
data-layers={Jason.encode!(@layers)} data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer} data-selected-layer={@selected_layer}
data-band-filter={@band_filter}
data-valid-times={@initial_valid_times_json} data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time} data-selected-time={@initial_selected_time}
data-initial-lat={@initial_center_lat} data-initial-lat={@initial_center_lat}
@ -425,6 +510,12 @@ defmodule MicrowavepropWeb.WeatherMapLive do
{layer_description(@layers, @selected_layer)} {layer_description(@layers, @selected_layer)}
</div> </div>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2"> <div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1"> <label class="flex items-center gap-2 cursor-pointer px-1">
<input <input
@ -576,6 +667,12 @@ defmodule MicrowavepropWeb.WeatherMapLive do
</div> </div>
</div> </div>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<%!-- Overlay toggles --%> <%!-- Overlay toggles --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2"> <div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1"> <label class="flex items-center gap-2 cursor-pointer px-1">

View file

@ -91,6 +91,8 @@ pub struct ScalarRow {
pub duct_base_m: Option<f64>, pub duct_base_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub duct_strength: Option<f64>, pub duct_strength: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duct_cutoff_ghz: Option<f64>,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@ -157,16 +159,24 @@ pub fn derive_row(
let inversion_base_m = compute_inversion_base_m(&sorted); let inversion_base_m = compute_inversion_base_m(&sorted);
// Ducts: the f01..f18 pipeline only stores the duct-summary scalars // Ducts: the f01..f18 pipeline only stores the duct-summary scalars
// (`max_duct_thickness_m`, `duct_count`) per cell. Duct base requires // (`max_duct_thickness_m`, `duct_count`, `best_duct_freq_ghz`) per
// the full Duct list, which isn't threaded through `CellValues` yet — // cell. Duct base requires the full Duct list, which isn't threaded
// matches the current Elixir behavior on Rust-produced profiles // through `CellValues` yet — matches the current Elixir behavior on
// (their `:duct_characteristics` is also nil). // Rust-produced profiles (their `:duct_characteristics` is also
// nil). `duct_cutoff_ghz` IS available because
// `native_duct::reduce_grid_to_ducts` reduces it to a scalar at
// ingest time.
let duct_count = cell.get("duct_count").copied().unwrap_or(0.0); let duct_count = cell.get("duct_count").copied().unwrap_or(0.0);
let duct_strength = if duct_count > 0.0 { let duct_strength = if duct_count > 0.0 {
cell.get("max_duct_thickness_m").map(|&v| v as f64) cell.get("max_duct_thickness_m").map(|&v| v as f64)
} else { } else {
None None
}; };
let duct_cutoff_ghz = if duct_count > 0.0 {
cell.get("best_duct_freq_ghz").map(|&v| v as f64)
} else {
None
};
Some(ScalarRow { Some(ScalarRow {
lat, lat,
@ -191,6 +201,7 @@ pub fn derive_row(
ducting, ducting,
duct_base_m: None, duct_base_m: None,
duct_strength, duct_strength,
duct_cutoff_ghz,
}) })
} }
@ -537,6 +548,36 @@ mod tests {
assert_eq!(temp.1.as_f64(), Some(22.5)); assert_eq!(temp.1.as_f64(), Some(22.5));
} }
#[test]
fn duct_cutoff_ghz_reads_best_duct_freq_from_cell() {
let mut cell = surface_only_cell();
cell.insert("duct_count".into(), 2.0);
cell.insert("max_duct_thickness_m".into(), 120.0);
cell.insert("best_duct_freq_ghz".into(), 18.4);
let vt = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
let row = derive_row(33.0, -97.0, vt, &cell).expect("row");
assert_eq!(row.duct_strength, Some(120.0));
let cutoff = row.duct_cutoff_ghz.expect("duct_cutoff_ghz");
assert!(
(cutoff - 18.4).abs() < 1e-3,
"expected ~18.4 GHz, got {cutoff}"
);
}
#[test]
fn duct_cutoff_ghz_nil_when_no_ducts() {
let mut cell = surface_only_cell();
cell.insert("duct_count".into(), 0.0);
cell.insert("best_duct_freq_ghz".into(), 18.4);
let vt = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
let row = derive_row(33.0, -97.0, vt, &cell).expect("row");
assert_eq!(row.duct_cutoff_ghz, None);
}
#[test] #[test]
fn chunk_band_floors_match_elixir() { fn chunk_band_floors_match_elixir() {
// floor(33.0 / 5) = 6, floor(-97.0 / 5) = -20 (matches Elixir's // floor(33.0 / 5) = 6, floor(-97.0 / 5) = -20 (matches Elixir's

View file

@ -58,7 +58,7 @@ defmodule Microwaveprop.Buildings.LoaderTest do
keys = Loader.ensure_loaded_for_bbox(bbox) keys = Loader.ensure_loaded_for_bbox(bbox)
assert is_list(keys) assert is_list(keys)
assert length(keys) > 0 assert keys != []
assert Enum.all?(keys, &is_binary/1) assert Enum.all?(keys, &is_binary/1)
end end
end end

View file

@ -26,7 +26,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0} bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
keys = MsFootprints.quadkeys_for_bbox(bbox, 9) keys = MsFootprints.quadkeys_for_bbox(bbox, 9)
assert is_list(keys) assert is_list(keys)
assert length(keys) > 0 assert keys != []
assert Enum.all?(keys, &is_binary/1) assert Enum.all?(keys, &is_binary/1)
end end

View file

@ -8,7 +8,7 @@ defmodule Microwaveprop.Weather.MapLayersTest do
layers = MapLayers.all() layers = MapLayers.all()
assert is_list(layers) assert is_list(layers)
assert length(layers) > 0 assert match?([_ | _], layers)
Enum.each(layers, fn layer -> Enum.each(layers, fn layer ->
assert Map.has_key?(layer, :id) assert Map.has_key?(layer, :id)

View file

@ -410,7 +410,7 @@ defmodule Microwaveprop.Weather.UntestedFunctionsTest do
test "finds station within radius" do test "finds station within radius" do
_ = create_station(%{station_code: "KDFW2", lat: 32.9, lon: -97.0}) _ = create_station(%{station_code: "KDFW2", lat: 32.9, lon: -97.0})
results = Weather.nearby_stations(32.9, -97.0, "asos", 50.0) results = Weather.nearby_stations(32.9, -97.0, "asos", 50.0)
assert length(results) >= 1 assert results != []
end end
end end

View file

@ -156,6 +156,58 @@ defmodule Microwaveprop.Weather.WeatherLayersTest do
end end
end end
describe "derive/1 duct cutoff frequency" do
test "takes the min min_freq_ghz across native-shape ducts" do
ducts = [
%{base_m: 0.0, top_m: 60.0, thickness_m: 60.0, m_deficit: 15.0, min_freq_ghz: 28.4},
%{base_m: 200.0, top_m: 320.0, thickness_m: 120.0, m_deficit: 12.0, min_freq_ghz: 12.7}
]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert_in_delta result.duct_cutoff_ghz, 12.7, 1.0e-6
end
test "accepts string-keyed native ducts (read back from msgpack)" do
ducts = [
%{"thickness_m" => 60.0, "m_deficit" => 15.0, "min_freq_ghz" => 28.4},
%{"thickness_m" => 120.0, "m_deficit" => 12.0, "min_freq_ghz" => 12.7}
]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert_in_delta result.duct_cutoff_ghz, 12.7, 1.0e-6
end
test "falls back to computing from thickness + m_deficit when min_freq_ghz missing" do
ducts = [%{thickness_m: 100.0, m_deficit: 10.0}]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
# Bean & Dutton: λ_max = 2.5·100·√(10·1e-6) ≈ 0.7906 m → f ≈ 0.379 GHz
expected = 3.0e8 / (2.5 * 100.0 * :math.sqrt(10.0 * 1.0e-6)) / 1.0e9
assert_in_delta result.duct_cutoff_ghz, expected, 1.0e-3
end
test "skips sounding-shape ducts that lack thickness/m_deficit" do
ducts = [
%{"base" => 500.0, "strength" => 12.5},
%{thickness_m: 120.0, m_deficit: 12.0, min_freq_ghz: 12.7}
]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert_in_delta result.duct_cutoff_ghz, 12.7, 1.0e-6
end
test "returns nil when no ducts have any computable cutoff" do
ducts = [%{"base" => 500.0, "strength" => 12.5}]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert result.duct_cutoff_ghz == nil
end
test "returns nil for nil and empty duct_characteristics" do
assert WeatherLayers.derive(sample_row(%{duct_characteristics: nil})).duct_cutoff_ghz == nil
assert WeatherLayers.derive(sample_row(%{duct_characteristics: []})).duct_cutoff_ghz == nil
end
end
describe "derive/1 nil/empty profile" do describe "derive/1 nil/empty profile" do
test "handles nil profile gracefully" do test "handles nil profile gracefully" do
result = WeatherLayers.derive(sample_row(%{profile: nil})) result = WeatherLayers.derive(sample_row(%{profile: nil}))

View file

@ -46,8 +46,8 @@ defmodule MicrowavepropWeb.Api.V1.ProfileControllerTest do
body = conn |> get(~p"/api/v1/profiles/W5TEST") |> json_response(200) body = conn |> get(~p"/api/v1/profiles/W5TEST") |> json_response(200)
assert body["user"]["callsign"] == "W5TEST" assert body["user"]["callsign"] == "W5TEST"
refute Map.has_key?(body["user"], "email") refute Map.has_key?(body["user"], "email")
assert length(body["contacts"]) >= 1 assert body["contacts"] != []
assert length(body["beacons"]) >= 1 assert body["beacons"] != []
end end
test "case-insensitive lookup", %{conn: conn} do test "case-insensitive lookup", %{conn: conn} do