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`.
186 lines
6.4 KiB
Elixir
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
|