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:
parent
43239dbb15
commit
e3d430f8c4
14 changed files with 487 additions and 26 deletions
|
|
@ -30,7 +30,18 @@ interface BooleanColorScale {
|
|||
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
|
||||
|
||||
|
|
@ -62,6 +73,7 @@ interface WeatherPoint {
|
|||
ducting: boolean | null
|
||||
duct_base_m: number | null
|
||||
duct_strength: number | null
|
||||
duct_cutoff_ghz: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +103,7 @@ interface WeatherMapHook extends ViewHook {
|
|||
// otherwise ["hrrr", "hrdps"] so the main map paints both grids.
|
||||
sources: SourceName[]
|
||||
selectedLayer: LayerId
|
||||
bandFilter: number | null
|
||||
packs: Map<SourceName, CellPack>
|
||||
packKey: string | null
|
||||
inflightAborts: Map<SourceName, AbortController>
|
||||
|
|
@ -141,6 +154,12 @@ function isMobile(): boolean {
|
|||
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 = {
|
||||
refractivity_gradient: {
|
||||
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",
|
||||
label: "Duct Strength",
|
||||
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>
|
||||
|
||||
|
|
@ -477,13 +517,31 @@ interface BoolPaintScale {
|
|||
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]
|
||||
if (!scale) return null
|
||||
if (scale.breakpoints === null) {
|
||||
const c = scale.trueColor
|
||||
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) }
|
||||
}
|
||||
|
||||
|
|
@ -500,6 +558,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
|
|||
onRemove: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay
|
||||
setPack: (this: WeatherCanvasOverlay, pack: CellPack | null) => void
|
||||
setLayer: (this: WeatherCanvasOverlay, layerId: LayerId) => void
|
||||
setBandFilter: (this: WeatherCanvasOverlay, band: number | null) => void
|
||||
_reset: (this: WeatherCanvasOverlay) => void
|
||||
_draw: (this: WeatherCanvasOverlay) => void
|
||||
} = {
|
||||
|
|
@ -507,6 +566,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
|
|||
this.pack = pack
|
||||
this.layerId = layerId
|
||||
this.halfStep = halfStep
|
||||
this.bandFilter = null
|
||||
},
|
||||
onAdd(map: L.Map): WeatherCanvasOverlay {
|
||||
this._map = map
|
||||
|
|
@ -534,6 +594,10 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
|
|||
this.layerId = layerId
|
||||
this._reset()
|
||||
},
|
||||
setBandFilter(band: number | null): void {
|
||||
this.bandFilter = band
|
||||
this._reset()
|
||||
},
|
||||
_reset(): void {
|
||||
if (!this._map) return
|
||||
const map = this._map
|
||||
|
|
@ -554,7 +618,7 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
|
|||
const values = this.pack.layers.get(this.layerId)
|
||||
if (!values) return
|
||||
|
||||
const paintScale = buildPaintScale(this.layerId)
|
||||
const paintScale = buildPaintScale(this.layerId, this.bandFilter)
|
||||
if (!paintScale) return
|
||||
|
||||
const map = this._map
|
||||
|
|
@ -576,6 +640,15 @@ const WeatherCanvasOverlayProto: L.LayerOptions & {
|
|||
if (paintScale.kind === "bool") {
|
||||
if (v < 0.5) continue
|
||||
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 {
|
||||
const t = (v - paintScale.lo) / (paintScale.hi - paintScale.lo)
|
||||
const idx = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255)
|
||||
|
|
@ -600,10 +673,12 @@ interface WeatherCanvasOverlay extends L.Layer {
|
|||
pack: CellPack | null
|
||||
layerId: LayerId
|
||||
halfStep: number
|
||||
bandFilter: number | null
|
||||
_map: L.Map
|
||||
_canvas: HTMLCanvasElement
|
||||
setPack(pack: CellPack | null): void
|
||||
setLayer(layerId: LayerId): void
|
||||
setBandFilter(band: number | null): void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -633,7 +708,8 @@ function buildDetailHTML(point: WeatherPoint): string {
|
|||
{ separator: true },
|
||||
{ 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 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()
|
||||
|
|
@ -706,6 +782,7 @@ export const WeatherMap: WeatherMapHook = {
|
|||
// CONUS-only viewport gets an empty HRDPS pack and vice versa.
|
||||
this.sources = this.source === "hrdps" ? ["hrdps"] : ["hrrr", "hrdps"]
|
||||
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
|
||||
this.bandFilter = parseBandFilter(this.el.dataset.bandFilter)
|
||||
this.packs = new Map()
|
||||
this.packKey = null
|
||||
this.inflightAborts = new Map()
|
||||
|
|
@ -731,6 +808,13 @@ export const WeatherMap: WeatherMapHook = {
|
|||
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")
|
||||
if (this.detailPanel) {
|
||||
L.DomEvent.disableClickPropagation(this.detailPanel)
|
||||
|
|
@ -875,16 +959,31 @@ export const WeatherMap: WeatherMapHook = {
|
|||
document.addEventListener("visibilitychange", this.visibilityHandler)
|
||||
|
||||
this._layerObserver = new MutationObserver(() => {
|
||||
let changed = false
|
||||
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
|
||||
if (newLayer && newLayer !== this.selectedLayer) {
|
||||
this.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.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.
|
||||
// Removing + re-adding the control on every renderLayer was a
|
||||
|
|
@ -1106,10 +1205,12 @@ export const WeatherMap: WeatherMapHook = {
|
|||
|
||||
if (!overlay) {
|
||||
const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src))
|
||||
newOverlay.bandFilter = this.bandFilter
|
||||
newOverlay.addTo(this.map)
|
||||
this.weatherOverlays.set(src, newOverlay)
|
||||
} else {
|
||||
overlay.pack = pack
|
||||
overlay.bandFilter = this.bandFilter
|
||||
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>`
|
||||
}
|
||||
|
||||
if (scale.breakpoints === "band_steps") {
|
||||
const steps = (scale as BandStepsColorScale).steps
|
||||
const heading = this.bandFilter != null
|
||||
? `${scale.label} · ≤ ${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)
|
||||
? `> ${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
|
||||
|
||||
if (mobile) {
|
||||
|
|
|
|||
99
docs/plans/2026-05-15-duct-cutoff-weather-layer.md
Normal file
99
docs/plans/2026-05-15-duct-cutoff-weather-layer.md
Normal 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).
|
||||
|
|
@ -144,6 +144,14 @@ defmodule Microwaveprop.Weather.MapLayers do
|
|||
group: "Ducting",
|
||||
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."
|
||||
},
|
||||
%{
|
||||
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."
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ defmodule Microwaveprop.Weather.ScalarFile do
|
|||
"inversion_base_m",
|
||||
"ducting",
|
||||
"duct_base_m",
|
||||
"duct_strength"
|
||||
"duct_strength",
|
||||
"duct_cutoff_ghz"
|
||||
])
|
||||
|
||||
@type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => term()}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Microwaveprop.Weather.WeatherLayers do
|
||||
@moduledoc "Derives scalar weather map layers from HRRR profile data."
|
||||
|
||||
alias Microwaveprop.Propagation.Duct
|
||||
alias Microwaveprop.Weather.SoundingParams
|
||||
|
||||
@doc """
|
||||
|
|
@ -30,10 +31,49 @@ defmodule Microwaveprop.Weather.WeatherLayers do
|
|||
inversion_strength: inversion_strength(sorted),
|
||||
inversion_base_m: inversion_base_m(sorted),
|
||||
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
|
||||
|
||||
# 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([]), do: []
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
|||
surface_refractivity refractivity_gradient bl_height pwat
|
||||
temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
|
||||
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
|
||||
|
||||
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
@layers MapLayers.all()
|
||||
@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
|
||||
# provided (the deep-link case). Centered on DFW (the project's home
|
||||
# area) at z=7 — same defaults the JS hook used to hardcode.
|
||||
|
|
@ -46,6 +50,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
page_title: "Weather Map",
|
||||
layers: @layers,
|
||||
selected_layer: @default_layer,
|
||||
cutoff_bands: @cutoff_bands,
|
||||
band_filter: nil,
|
||||
valid_time: initial_vt,
|
||||
valid_times: valid_times,
|
||||
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
|
||||
|
|
@ -65,6 +71,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
|
||||
band = pick_band(params["band"], selected)
|
||||
|
||||
# Update current viewport so subsequent push_patch URLs combine
|
||||
# `layer=` with whatever lat/lon/zoom the user has navigated to.
|
||||
|
|
@ -75,6 +82,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_layer, selected)
|
||||
|> assign(:band_filter, band)
|
||||
|> assign(:current_lat, lat)
|
||||
|> assign(:current_lon, lon)
|
||||
|> 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), 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
|
||||
# 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
|
||||
|
|
@ -148,6 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
# `select_layer`) tweak one field without losing the others.
|
||||
defp build_path(socket, overrides \\ []) do
|
||||
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)
|
||||
lon = Keyword.get(overrides, :lon, socket.assigns.current_lon)
|
||||
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
|
||||
# asserting on the exact URL, and humans reading shareable links,
|
||||
# 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 =
|
||||
URI.encode_query([
|
||||
{"layer", layer},
|
||||
{"lat", lat},
|
||||
{"lon", lon},
|
||||
{"zoom", zoom}
|
||||
])
|
||||
URI.encode_query(base ++ band_params ++ [{"lat", lat}, {"lon", lon}, {"zoom", zoom}])
|
||||
|
||||
"/weather?#{query}"
|
||||
end
|
||||
|
|
@ -169,12 +188,42 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
@impl true
|
||||
def handle_event("select_layer", %{"layer" => layer_id}, socket) 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
|
||||
{:noreply, socket}
|
||||
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
|
||||
# patches the URL so a refresh / share-link lands the user back at
|
||||
# the same view. Stays out of the LiveView assigns hot path —
|
||||
|
|
@ -328,6 +377,41 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
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
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -341,6 +425,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
phx-update="ignore"
|
||||
data-layers={Jason.encode!(@layers)}
|
||||
data-selected-layer={@selected_layer}
|
||||
data-band-filter={@band_filter}
|
||||
data-valid-times={@initial_valid_times_json}
|
||||
data-selected-time={@initial_selected_time}
|
||||
data-initial-lat={@initial_center_lat}
|
||||
|
|
@ -425,6 +510,12 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
{layer_description(@layers, @selected_layer)}
|
||||
</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">
|
||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
|
|
@ -576,6 +667,12 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<.cutoff_band_picker
|
||||
:if={@selected_layer == "duct_cutoff_ghz"}
|
||||
bands={@cutoff_bands}
|
||||
selected={@band_filter}
|
||||
/>
|
||||
|
||||
<%!-- Overlay toggles --%>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ pub struct ScalarRow {
|
|||
pub duct_base_m: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duct_strength: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duct_cutoff_ghz: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -157,16 +159,24 @@ pub fn derive_row(
|
|||
let inversion_base_m = compute_inversion_base_m(&sorted);
|
||||
|
||||
// Ducts: the f01..f18 pipeline only stores the duct-summary scalars
|
||||
// (`max_duct_thickness_m`, `duct_count`) per cell. Duct base requires
|
||||
// the full Duct list, which isn't threaded through `CellValues` yet —
|
||||
// matches the current Elixir behavior on Rust-produced profiles
|
||||
// (their `:duct_characteristics` is also nil).
|
||||
// (`max_duct_thickness_m`, `duct_count`, `best_duct_freq_ghz`) per
|
||||
// cell. Duct base requires the full Duct list, which isn't threaded
|
||||
// through `CellValues` yet — matches the current Elixir behavior on
|
||||
// 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_strength = if duct_count > 0.0 {
|
||||
cell.get("max_duct_thickness_m").map(|&v| v as f64)
|
||||
} else {
|
||||
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 {
|
||||
lat,
|
||||
|
|
@ -191,6 +201,7 @@ pub fn derive_row(
|
|||
ducting,
|
||||
duct_base_m: None,
|
||||
duct_strength,
|
||||
duct_cutoff_ghz,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -537,6 +548,36 @@ mod tests {
|
|||
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]
|
||||
fn chunk_band_floors_match_elixir() {
|
||||
// floor(33.0 / 5) = 6, floor(-97.0 / 5) = -20 (matches Elixir's
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ defmodule Microwaveprop.Buildings.LoaderTest do
|
|||
keys = Loader.ensure_loaded_for_bbox(bbox)
|
||||
|
||||
assert is_list(keys)
|
||||
assert length(keys) > 0
|
||||
assert keys != []
|
||||
assert Enum.all?(keys, &is_binary/1)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
|
|||
bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
|
||||
keys = MsFootprints.quadkeys_for_bbox(bbox, 9)
|
||||
assert is_list(keys)
|
||||
assert length(keys) > 0
|
||||
assert keys != []
|
||||
assert Enum.all?(keys, &is_binary/1)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ defmodule Microwaveprop.Weather.MapLayersTest do
|
|||
layers = MapLayers.all()
|
||||
|
||||
assert is_list(layers)
|
||||
assert length(layers) > 0
|
||||
assert match?([_ | _], layers)
|
||||
|
||||
Enum.each(layers, fn layer ->
|
||||
assert Map.has_key?(layer, :id)
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ defmodule Microwaveprop.Weather.UntestedFunctionsTest do
|
|||
test "finds station within radius" do
|
||||
_ = create_station(%{station_code: "KDFW2", lat: 32.9, lon: -97.0})
|
||||
results = Weather.nearby_stations(32.9, -97.0, "asos", 50.0)
|
||||
assert length(results) >= 1
|
||||
assert results != []
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,58 @@ defmodule Microwaveprop.Weather.WeatherLayersTest do
|
|||
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
|
||||
test "handles nil profile gracefully" do
|
||||
result = WeatherLayers.derive(sample_row(%{profile: nil}))
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ defmodule MicrowavepropWeb.Api.V1.ProfileControllerTest do
|
|||
body = conn |> get(~p"/api/v1/profiles/W5TEST") |> json_response(200)
|
||||
assert body["user"]["callsign"] == "W5TEST"
|
||||
refute Map.has_key?(body["user"], "email")
|
||||
assert length(body["contacts"]) >= 1
|
||||
assert length(body["beacons"]) >= 1
|
||||
assert body["contacts"] != []
|
||||
assert body["beacons"] != []
|
||||
end
|
||||
|
||||
test "case-insensitive lookup", %{conn: conn} do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue