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
|
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} · ≤ ${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
|
const bp = (scale as ContinuousColorScale).breakpoints
|
||||||
|
|
||||||
if (mobile) {
|
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",
|
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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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: []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue