Direct queries against prod (75M HRRR profiles, 16,864 soundings, 392k surface obs, new hrrr_native_profiles table) drive these changes: * Reinstate shallow-BL bonus as an HPBL multiplier on the refractivity score (1.10× <200m → 0.78× ≥2000m). The Apr 11 "shallow BL bonus removed" conclusion was an artifact of the prior matching strategy; with the cleaner contact↔HRRR join (n=680) the binned data goes 230 km avg at HPBL <200m vs 100 km at ≥2000m, monotonic across all bins. * Add 6 missing bands (142, 145, 288, 322, 403, 411 GHz) so contacts in those bands stop being silently dropped. Coefficients extrapolate ITU-R P.676/P.838 trends from the existing 134/241 GHz entries. * Bump ERA5 poll timeout 10 min → 1 hour and Era5MonthBatchWorker max_attempts 3 → 5 so CDS slowness stops discarding tiles. Also dump the full failure body when CDS omits the message field — the prior "ERA5 job failed: nil" was masking real error reasons in oban_jobs. * Add scripts/recalibrate_algo.py so this analysis can be re-run any time new data lands without manual SQL. Reads PROP_PROD_DB_URL from .envrc, drops a Markdown report into docs/algo-reports/. * Append a dated Part 2c to algo.md documenting the corpus expansion, the honest accounting of historical HRRR coverage (only ~1,020 of 58k contacts are precision-matchable), and the empty era5/rtma/climatology tables. Update the gaseous-absorption and rain-attenuation tables to include the new bands. Test suite: 1,335 tests, 0 failures.
256 lines
8.5 KiB
Elixir
256 lines
8.5 KiB
Elixir
defmodule Microwaveprop.PropagationTest do
|
|
use Microwaveprop.DataCase, async: false
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.GridScore
|
|
alias Microwaveprop.Propagation.ScoreCache
|
|
|
|
setup do
|
|
ScoreCache.clear()
|
|
:ok
|
|
end
|
|
|
|
describe "score_grid_point/4" do
|
|
test "scores a single point for all bands" do
|
|
hrrr_profile = %{
|
|
surface_temp_c: 25.0,
|
|
surface_dewpoint_c: 18.0,
|
|
surface_pressure_mb: 1013.0,
|
|
hpbl_m: 500.0,
|
|
wind_u: 3.0,
|
|
wind_v: 2.0,
|
|
cloud_cover_pct: 15.0,
|
|
precip_mm: 0.0,
|
|
profile: [
|
|
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
|
|
%{"pres" => 975.0, "tmpc" => 22.0, "dwpc" => 15.0, "hght" => 350.0},
|
|
%{"pres" => 950.0, "tmpc" => 19.0, "dwpc" => 10.0, "hght" => 600.0}
|
|
]
|
|
}
|
|
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
results = Propagation.score_grid_point(hrrr_profile, valid_time, 33.0, -97.0)
|
|
|
|
assert length(results) == 19
|
|
|
|
Enum.each(results, fn result ->
|
|
assert result.score >= 0 and result.score <= 100
|
|
assert is_map(result.factors)
|
|
assert is_integer(result.band_mhz)
|
|
end)
|
|
end
|
|
end
|
|
|
|
describe "upsert_scores/1" do
|
|
test "inserts scores" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
scores = [
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{humidity: 90}},
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 24_000, score: 60, factors: %{humidity: 40}}
|
|
]
|
|
|
|
assert {:ok, 2} = Propagation.upsert_scores(scores)
|
|
assert Repo.aggregate(GridScore, :count) == 2
|
|
end
|
|
|
|
test "upserts on conflict" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
scores = [
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}}
|
|
]
|
|
|
|
Propagation.upsert_scores(scores)
|
|
Propagation.upsert_scores([%{hd(scores) | score: 80}])
|
|
|
|
assert Repo.aggregate(GridScore, :count) == 1
|
|
end
|
|
end
|
|
|
|
describe "latest_scores/1" do
|
|
test "returns latest scores for a band" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
scores = [
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}},
|
|
%{lat: 36.0, lon: -96.0, valid_time: valid_time, band_mhz: 10_000, score: 80, factors: %{}}
|
|
]
|
|
|
|
Propagation.upsert_scores(scores)
|
|
results = Propagation.latest_scores(10_000)
|
|
assert length(results) == 2
|
|
end
|
|
|
|
test "returns empty list when no data" do
|
|
assert Propagation.latest_scores(10_000) == []
|
|
end
|
|
end
|
|
|
|
describe "scores_at/3 with cache" do
|
|
test "populates the cache on a DB miss" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
Propagation.upsert_scores([
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}}
|
|
])
|
|
|
|
assert ScoreCache.fetch(10_000, valid_time) == :miss
|
|
_ = Propagation.scores_at(10_000, valid_time)
|
|
assert {:ok, [%{lat: 35.0, lon: -97.0, score: 75}]} = ScoreCache.fetch(10_000, valid_time)
|
|
end
|
|
|
|
test "returns cached bounds-filtered results without hitting the DB" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
# Seed the cache directly — DB is empty, so if the result matches the
|
|
# cached payload we know the DB was not consulted.
|
|
ScoreCache.put(10_000, valid_time, [
|
|
%{lat: 32.0, lon: -97.0, score: 75},
|
|
%{lat: 40.0, lon: -74.0, score: 50}
|
|
])
|
|
|
|
assert Repo.aggregate(GridScore, :count) == 0
|
|
|
|
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
|
|
result = Propagation.scores_at(10_000, valid_time, bounds)
|
|
|
|
assert length(result) == 1
|
|
assert [%{lat: 32.0, lon: -97.0, score: 75}] = result
|
|
end
|
|
|
|
test "returns empty list when no data in cache or DB" do
|
|
assert Propagation.scores_at(10_000, ~U[2026-07-15 13:00:00Z]) == []
|
|
end
|
|
end
|
|
|
|
describe "point_forecast/3 with cache" do
|
|
test "returns forecast timeline from cached scores when warm" do
|
|
t1 = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
|
|
t2 = DateTime.add(t1, 3600, :second)
|
|
|
|
ScoreCache.put(10_000, t1, [%{lat: 32.875, lon: -97.0, score: 70}])
|
|
ScoreCache.put(10_000, t2, [%{lat: 32.875, lon: -97.0, score: 75}])
|
|
|
|
assert Repo.aggregate(GridScore, :count) == 0
|
|
|
|
result = Propagation.point_forecast(10_000, 32.875, -97.0)
|
|
|
|
assert [
|
|
%{valid_time: ^t1, score: 70},
|
|
%{valid_time: ^t2, score: 75}
|
|
] = result
|
|
end
|
|
|
|
test "filters past valid_times out" do
|
|
past = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
|
|
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
|
|
|
|
ScoreCache.put(10_000, past, [%{lat: 32.875, lon: -97.0, score: 60}])
|
|
ScoreCache.put(10_000, future, [%{lat: 32.875, lon: -97.0, score: 80}])
|
|
|
|
result = Propagation.point_forecast(10_000, 32.875, -97.0)
|
|
assert [%{valid_time: ^future, score: 80}] = result
|
|
end
|
|
|
|
test "snaps coordinates to the nearest grid point" do
|
|
valid_time = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
|
|
ScoreCache.put(10_000, valid_time, [%{lat: 32.875, lon: -97.0, score: 65}])
|
|
|
|
# Off-grid query snaps to 32.875, -97.0
|
|
result = Propagation.point_forecast(10_000, 32.88, -96.98)
|
|
assert [%{valid_time: ^valid_time, score: 65}] = result
|
|
end
|
|
|
|
test "returns empty list when no cached or DB data for the point" do
|
|
assert Propagation.point_forecast(10_000, 32.875, -97.0) == []
|
|
end
|
|
end
|
|
|
|
describe "available_valid_times/1 with cache" do
|
|
test "returns cached valid_times without hitting the DB when cache is warm" do
|
|
t1 = DateTime.truncate(DateTime.utc_now(), :second)
|
|
t2 = DateTime.add(t1, 3600, :second)
|
|
|
|
ScoreCache.put(10_000, t1, [])
|
|
ScoreCache.put(10_000, t2, [])
|
|
|
|
assert Repo.aggregate(GridScore, :count) == 0
|
|
assert Propagation.available_valid_times(10_000) == [t1, t2]
|
|
end
|
|
|
|
test "filters cached times older than the 1-hour cutoff" do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
stale = DateTime.add(now, -7200, :second)
|
|
fresh = DateTime.add(now, 1800, :second)
|
|
|
|
ScoreCache.put(10_000, stale, [])
|
|
ScoreCache.put(10_000, fresh, [])
|
|
|
|
assert Propagation.available_valid_times(10_000) == [fresh]
|
|
end
|
|
|
|
test "falls back to DB on cold cache" do
|
|
valid_time = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
Propagation.upsert_scores([
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}}
|
|
])
|
|
|
|
assert ScoreCache.valid_times(10_000) == []
|
|
assert Propagation.available_valid_times(10_000) == [valid_time]
|
|
end
|
|
end
|
|
|
|
describe "warm_cache_and_broadcast/2" do
|
|
test "loads scores from DB into the cache" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
Propagation.upsert_scores([
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}},
|
|
%{lat: 36.0, lon: -96.0, valid_time: valid_time, band_mhz: 10_000, score: 80, factors: %{}}
|
|
])
|
|
|
|
assert ScoreCache.fetch(10_000, valid_time) == :miss
|
|
|
|
Propagation.warm_cache_and_broadcast(10_000, valid_time)
|
|
ScoreCache.sync()
|
|
|
|
assert {:ok, scores} = ScoreCache.fetch(10_000, valid_time)
|
|
assert length(scores) == 2
|
|
end
|
|
|
|
test "only warms the requested band" do
|
|
valid_time = ~U[2026-07-15 13:00:00Z]
|
|
|
|
Propagation.upsert_scores([
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}},
|
|
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 24_000, score: 30, factors: %{}}
|
|
])
|
|
|
|
Propagation.warm_cache_and_broadcast(10_000, valid_time)
|
|
ScoreCache.sync()
|
|
|
|
assert {:ok, [_]} = ScoreCache.fetch(10_000, valid_time)
|
|
assert ScoreCache.fetch(24_000, valid_time) == :miss
|
|
end
|
|
end
|
|
|
|
describe "latest_valid_time/0" do
|
|
test "returns nil when empty" do
|
|
assert Propagation.latest_valid_time() == nil
|
|
end
|
|
|
|
test "returns most recent time" do
|
|
t1 = ~U[2026-07-15 12:00:00Z]
|
|
t2 = ~U[2026-07-15 13:00:00Z]
|
|
|
|
Propagation.upsert_scores([
|
|
%{lat: 35.0, lon: -97.0, valid_time: t1, band_mhz: 10_000, score: 50, factors: %{}},
|
|
%{lat: 35.0, lon: -97.0, valid_time: t2, band_mhz: 10_000, score: 60, factors: %{}}
|
|
])
|
|
|
|
assert Propagation.latest_valid_time() == t2
|
|
end
|
|
end
|
|
end
|