prop/test/microwaveprop/propagation/asos_nudge_test.exs
Graham McIntire c318c3a932
Nudge HRRR scores with live ASOS obs between runs
Adds Microwaveprop.Propagation.AsosNudge: a pure IDW bias-field module
that takes ASOS observations + HRRR profiles and returns re-scored grid
rows for every cell within 250km of a reporting station. Upper-air
fields (min_refractivity_gradient, pwat_mm, hpbl_m, profile, duct
metadata) pass through unchanged so HRRR's signal isn't clobbered.

The old AsosAdjustmentWorker was unwired and buggy — nil'd out ~22% of
the scoring weight and wrote orphan timestamps. Replaced with a slim
worker that queries the latest HRRR valid_time, fetches live ASOS
currents, calls AsosNudge.compute/3, and upserts onto
(lat, lon, valid_time, band_mhz) so nudged values overwrite the HRRR
hour cleanly instead of polluting available_valid_times. After each
upsert it warms ScoreCache and broadcasts propagation:updated so live
/map clients refresh.

Cron hooked up every 10 minutes in config.exs and dev.exs. Also cleaned
up the stale "dev has propagation disabled" note in CLAUDE.md.

13 new AsosNudge unit tests cover: residual computation (co-located,
out-of-grid, nil fields), IDW weighting (single station, far station,
two equidistant stations, nil component handling), upper-air
preservation, and the compute/3 entry point's shape and radius filter.

Drive-by Styler formatting touched a handful of unrelated files from
`mix format`.
2026-04-12 14:27:27 -05:00

186 lines
6.4 KiB
Elixir

defmodule Microwaveprop.Propagation.AsosNudgeTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.AsosNudge
@valid_time ~U[2026-04-12 18:00:00Z]
defp profile(lat, lon, overrides \\ []) do
Map.merge(
%{
valid_time: @valid_time,
lat: lat,
lon: lon,
surface_temp_c: 20.0,
surface_dewpoint_c: 10.0,
surface_pressure_mb: 1013.0,
hpbl_m: 1000.0,
pwat_mm: 25.0,
min_refractivity_gradient: -40.0,
surface_refractivity: 320.0,
profile: []
},
Map.new(overrides)
)
end
defp station(lat, lon, overrides \\ []) do
Map.merge(
%{
lat: lat,
lon: lon,
temp_f: 68.0,
dewpoint_f: 50.0,
sea_level_pressure_mb: 1013.0,
precip_1h_in: 0.0
},
Map.new(overrides)
)
end
describe "build_station_residuals/2" do
test "station co-located with a HRRR grid cell yields residuals against that cell" do
station_obs = station(32.0, -97.0, temp_f: 77.0, dewpoint_f: 59.0, sea_level_pressure_mb: 1020.0)
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
[residual] = AsosNudge.build_station_residuals([station_obs], [hrrr])
assert_in_delta residual.dtemp_c, 5.0, 0.1
assert_in_delta residual.ddewpoint_c, 5.0, 0.1
assert_in_delta residual.dpressure_mb, 7.0, 0.1
assert residual.lat == 32.0
assert residual.lon == -97.0
end
test "station with no HRRR cell within 1 grid step is dropped" do
obs = station(32.0, -97.0, temp_f: 77.0)
far_hrrr = profile(40.0, -85.0, surface_temp_c: 20.0)
assert AsosNudge.build_station_residuals([obs], [far_hrrr]) == []
end
test "station with nil temp is dropped" do
obs = station(32.0, -97.0, temp_f: nil)
hrrr = profile(32.0, -97.0)
assert AsosNudge.build_station_residuals([obs], [hrrr]) == []
end
test "station with nil pressure still contributes temp and dewpoint deltas" do
obs = station(32.0, -97.0, temp_f: 77.0, dewpoint_f: 59.0, sea_level_pressure_mb: nil)
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
[residual] = AsosNudge.build_station_residuals([obs], [hrrr])
assert_in_delta residual.dtemp_c, 5.0, 0.1
assert_in_delta residual.ddewpoint_c, 5.0, 0.1
assert residual.dpressure_mb == nil
end
end
describe "nudge_profile/2" do
test "single station at the same grid cell applies (almost) the full residual" do
residuals = [
%{lat: 32.0, lon: -97.0, dtemp_c: 5.0, ddewpoint_c: 3.0, dpressure_mb: 2.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert_in_delta nudged.surface_temp_c, 25.0, 0.1
assert_in_delta nudged.surface_dewpoint_c, 13.0, 0.1
assert_in_delta nudged.surface_pressure_mb, 1015.0, 0.1
end
test "station farther than 250 km contributes nothing" do
residuals = [
%{lat: 40.0, lon: -97.0, dtemp_c: 10.0, ddewpoint_c: 10.0, dpressure_mb: 5.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert nudged.surface_temp_c == 20.0
assert nudged.surface_dewpoint_c == 10.0
assert nudged.surface_pressure_mb == 1013.0
end
test "two equidistant stations with opposite residuals average to near zero" do
residuals = [
%{lat: 32.0, lon: -95.93, dtemp_c: 5.0, ddewpoint_c: 0.0, dpressure_mb: 0.0, asos_rain_mmhr: 0.0},
%{lat: 32.0, lon: -98.07, dtemp_c: -5.0, ddewpoint_c: 0.0, dpressure_mb: 0.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert_in_delta nudged.surface_temp_c, 20.0, 0.1
end
test "nil residual components (missing pressure) leave the HRRR field unchanged" do
residuals = [
%{lat: 32.0, lon: -97.0, dtemp_c: 5.0, ddewpoint_c: 3.0, dpressure_mb: nil, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert_in_delta nudged.surface_temp_c, 25.0, 0.1
assert_in_delta nudged.surface_dewpoint_c, 13.0, 0.1
assert nudged.surface_pressure_mb == 1013.0
end
test "upper-air fields pass through unchanged regardless of residuals" do
residuals = [
%{lat: 32.0, lon: -97.0, dtemp_c: 10.0, ddewpoint_c: 10.0, dpressure_mb: 20.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, min_refractivity_gradient: -250.0, pwat_mm: 35.0, hpbl_m: 500.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert nudged.min_refractivity_gradient == -250.0
assert nudged.pwat_mm == 35.0
assert nudged.hpbl_m == 500.0
end
end
describe "compute/3" do
test "returns empty list when observations list is empty" do
assert AsosNudge.compute([], @valid_time, [profile(32.0, -97.0)]) == []
end
test "returns empty list when no HRRR profiles provided" do
assert AsosNudge.compute([station(32.0, -97.0)], @valid_time, []) == []
end
test "returns grid score maps for nudged profiles with band_mhz, score, factors" do
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0)
obs = station(32.0, -97.0, temp_f: 77.0)
results = AsosNudge.compute([obs], @valid_time, [hrrr])
assert length(results) > 0
for result <- results do
assert result.lat == 32.0
assert result.lon == -97.0
assert result.valid_time == @valid_time
assert is_integer(result.band_mhz)
assert is_number(result.score)
assert is_map(result.factors)
end
end
test "drops grid points with no station within 250 km from the output" do
nudged_cell = profile(32.0, -97.0, surface_temp_c: 20.0)
far_cell = profile(40.0, -85.0, surface_temp_c: 20.0)
obs = station(32.0, -97.0, temp_f: 77.0)
results = AsosNudge.compute([obs], @valid_time, [nudged_cell, far_cell])
lat_lons = results |> Enum.map(&{&1.lat, &1.lon}) |> Enum.uniq()
assert {32.0, -97.0} in lat_lons
refute {40.0, -85.0} in lat_lons
end
end
end