prop/test/microwaveprop/propagation_test.exs
Graham McIntire 0c98707091
feat: harden /map analysis breakdown + move Plausible to root layout
Two fixes cover the blank "analysis breakdown" panel the user reported:

1. Bound the point_detail fallback lookback to 24h. Previously
   factors_from_fallback_profile would happily use a week-old analysis
   profile when no current one existed — now anything older than 24h
   returns an empty factor map so the UI can surface "breakdown
   unavailable" instead of silently misattributing.

2. Move the Plausible analytics snippet from Layouts.app into
   root.html.heex. Full-bleed LiveViews (/map, /contacts/map,
   /weather, home) bypass Layouts.app, so the snippet only loaded on
   the navbar-wrapped pages. root.html.heex is loaded once per HTTP
   document so coverage is now universal.

Added ~30 tests locking both down:
  • point_detail fallback: exact-time wins, 24h boundary accepted,
    30h-stale rejected, multi-band coverage, missing-cell degrades to
    empty factors, default-valid_time path
  • analytics_test.exs: Plausible script present on 12 representative
    pages, exactly once, loaded async, surviving a login round-trip

Also fixed pre-existing credo issues per standing rule: shortened the
map_live_test ETS match_delete call, extracted a function from the
deeply-nested hrrr_point_enqueuer.enqueue_for_contacts/1 cond, and
swapped Mix.Tasks.Rust.Golden's IO.inspect for Mix.shell().info.
2026-04-21 15:56:30 -05:00

1051 lines
38 KiB
Elixir

defmodule Microwaveprop.PropagationTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Propagation.ScoresFile
setup do
ScoreCache.clear()
# Tests share a single `propagation_scores_dir` across the run, so
# profiles left behind by one test can surface inside another — most
# visibly in the point_detail fallback tests where "no profile for
# this point in the last 24h" is the scenario under assertion.
# Wipe the profiles tree before each test.
base = Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores")
profiles_dir = Path.join(base, "profiles")
File.rm_rf(profiles_dir)
:ets.match_delete(
:microwaveprop_cache,
{{Microwaveprop.Propagation.ProfilesFile, :_, :_, :_}, :_, :_}
)
:ets.match_delete(
:microwaveprop_cache,
{{Microwaveprop.Propagation.ProfilesFile, :_, :_}, :_, :_}
)
: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) == 23
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
test "NEXRAD reflectivity drops the 24 GHz rain score when HRRR precip_mm is 0" do
# Same profile, once without NEXRAD and once with a heavy-rain dBZ. The
# 24 GHz rain-score factor must drop because NEXRAD can report rain that
# HRRR's hourly precip accumulation hasn't caught yet.
base_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" => 950.0, "tmpc" => 19.0, "dwpc" => 10.0, "hght" => 600.0}
]
}
valid_time = ~U[2026-07-15 13:00:00Z]
without = Propagation.score_grid_point(base_profile, valid_time, 33.0, -97.0)
with_nexrad =
base_profile
|> Map.put(:nexrad_max_reflectivity_dbz, 45.0)
|> Propagation.score_grid_point(valid_time, 33.0, -97.0)
rain_dry = Enum.find(without, &(&1.band_mhz == 24_000)).factors.rain
rain_wet = Enum.find(with_nexrad, &(&1.band_mhz == 24_000)).factors.rain
assert rain_wet < rain_dry
end
test "uses a persisted min_refractivity_gradient scalar without decoding the profile array" do
# AsosAdjustmentWorker loads 92k HRRR profile rows per 10-min tick and
# can't afford to pull the ~1 KB JSONB `profile` column for each one —
# Postgrex's Jason.decode! per row blew past the 15s pool checkout.
# hrrr_profiles persists min_refractivity_gradient as a scalar at
# ingestion time, so score_grid_point must honour it when the profile
# array is absent instead of trying to re-derive from a missing list.
hrrr_profile_without_array = %{
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 28.0,
wind_u: 3.0,
wind_v: 2.0,
cloud_cover_pct: 15.0,
precip_mm: 0.0,
min_refractivity_gradient: -400.0
}
valid_time = ~U[2026-07-15 13:00:00Z]
results = Propagation.score_grid_point(hrrr_profile_without_array, valid_time, 33.0, -97.0)
assert length(results) == 23
Enum.each(results, fn result ->
assert result.score >= 0 and result.score <= 100
end)
result_10g = Enum.find(results, &(&1.band_mhz == 10_000))
# A strong negative refractivity gradient (< -300 N/km) should leave
# the refractivity factor visibly positive; if score_grid_point had
# silently used 0.0 we'd see the neutral baseline instead.
assert result_10g.factors.refractivity > 55
end
test "NEXRAD is ignored when HRRR precip_mm already reports heavier rain" do
base_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,
# 30 mm/hr → far above the ~3 mm/hr NEXRAD gives for 30 dBZ, so HRRR wins.
precip_mm: 30.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 950.0, "tmpc" => 19.0, "dwpc" => 10.0, "hght" => 600.0}
]
}
valid_time = ~U[2026-07-15 13:00:00Z]
with_light_nexrad =
base_profile
|> Map.put(:nexrad_max_reflectivity_dbz, 30.0)
|> Propagation.score_grid_point(valid_time, 33.0, -97.0)
without = Propagation.score_grid_point(base_profile, valid_time, 33.0, -97.0)
rain_no_nx = Enum.find(without, &(&1.band_mhz == 24_000)).factors.rain
rain_with_nx = Enum.find(with_light_nexrad, &(&1.band_mhz == 24_000)).factors.rain
assert rain_with_nx == rain_no_nx
end
end
describe "replace_scores/2" do
test "writes one ScoresFile per band for a fresh valid_time" do
valid_time = ~U[2026-07-15 13:00:00Z]
scores = [
%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil},
%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 24_000, score: 60, factors: nil}
]
assert {:ok, 2} = Propagation.replace_scores(scores, valid_time)
assert {:ok, %{band_mhz: 10_000}} = ScoresFile.read(10_000, valid_time)
assert {:ok, %{band_mhz: 24_000}} = ScoresFile.read(24_000, valid_time)
end
test "overwrites the file when called again for the same valid_time" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 10, factors: nil}],
valid_time
)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil}],
valid_time
)
assert ScoresFile.read_point(10_000, valid_time, 25.0, -125.0) == 75
end
test "leaves files for other valid_times untouched" do
other_time = ~U[2026-07-15 12:00:00Z]
replaced_time = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: other_time, band_mhz: 10_000, score: 40, factors: nil}],
other_time
)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: replaced_time, band_mhz: 10_000, score: 90, factors: nil}],
replaced_time
)
assert ScoresFile.read_point(10_000, other_time, 25.0, -125.0) == 40
assert ScoresFile.read_point(10_000, replaced_time, 25.0, -125.0) == 90
end
end
describe "point_detail/4" do
test "returns the score from the on-disk file with an empty factors map when no profile is persisted" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 42, factors: nil}],
valid_time
)
assert %{score: 42, factors: %{}} =
Propagation.point_detail(10_000, 25.0, -125.0, valid_time)
end
test "rebuilds the factor breakdown from a persisted f00 profile" do
valid_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75
lon = -97.125
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}
]
}
ProfilesFile.write!(valid_time, %{{lat, lon} => profile})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: valid_time, band_mhz: 10_000, score: 71, factors: nil}],
valid_time
)
detail = Propagation.point_detail(10_000, lat, lon, valid_time)
assert detail.score == 71
assert is_map(detail.factors)
# The algorithm populates every weighted factor; humidity is the
# dominant input for 10 GHz so it must be present.
assert Map.has_key?(detail.factors, :humidity)
end
test "returns nil when the file doesn't exist for the requested point" do
assert Propagation.point_detail(10_000, 25.0, -125.0, ~U[2026-07-15 13:00:00Z]) == nil
end
test "falls back to the latest persisted profile when the requested hour is a forecast hour" do
analysis_time = ~U[2026-07-15 13:00:00Z]
forecast_time = ~U[2026-07-15 15:00:00Z]
lat = 32.75
lon = -97.125
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}
]
}
# Profile persisted only for the analysis hour (f00); forecast
# hours never persist profiles, so the click detail would
# otherwise come back with an empty factors map.
ProfilesFile.write!(analysis_time, %{{lat, lon} => profile})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: forecast_time, band_mhz: 10_000, score: 64, factors: nil}],
forecast_time
)
detail = Propagation.point_detail(10_000, lat, lon, forecast_time)
assert detail.score == 64
assert is_map(detail.factors)
assert Map.has_key?(detail.factors, :humidity)
end
test "uses the most recent profile at-or-before the requested time when several analysis hours exist" do
older = ~U[2026-07-15 10:00:00Z]
newer = ~U[2026-07-15 12:00:00Z]
forecast_time = ~U[2026-07-15 14:00:00Z]
lat = 32.75
lon = -97.125
older_profile = build_profile(temp_c: 30.0, dewpoint_c: 5.0)
newer_profile = build_profile(temp_c: 22.0, dewpoint_c: 18.0)
ProfilesFile.write!(older, %{{lat, lon} => older_profile})
ProfilesFile.write!(newer, %{{lat, lon} => newer_profile})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: forecast_time, band_mhz: 10_000, score: 55, factors: nil}],
forecast_time
)
detail = Propagation.point_detail(10_000, lat, lon, forecast_time)
# The newer profile has a much higher dewpoint, which drives a
# distinctly different humidity factor than the older one.
# Asserting the humidity reflects `newer_profile` proves the
# fallback picked the latest past analysis, not the oldest.
newer_humidity = humidity_factor_for(lat, lon, newer_profile, newer, 10_000)
assert detail.factors.humidity == newer_humidity
end
test "returns empty factors when the only persisted profile is older than the 24h lookback" do
ancient = DateTime.add(~U[2026-07-15 13:00:00Z], -30 * 3600, :second)
forecast_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75
lon = -97.125
ProfilesFile.write!(ancient, %{{lat, lon} => build_profile()})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: forecast_time, band_mhz: 10_000, score: 40, factors: nil}],
forecast_time
)
detail = Propagation.point_detail(10_000, lat, lon, forecast_time)
assert detail.score == 40
# 30h > 24h lookback — the UI needs to show "breakdown unavailable"
# rather than attribute the current score to a day-old pattern.
assert detail.factors == %{}
end
test "accepts a profile exactly at the 24h lookback boundary" do
fallback_time = DateTime.add(~U[2026-07-15 13:00:00Z], -24 * 3600, :second)
forecast_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75
lon = -97.125
ProfilesFile.write!(fallback_time, %{{lat, lon} => build_profile()})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: forecast_time, band_mhz: 10_000, score: 50, factors: nil}],
forecast_time
)
detail = Propagation.point_detail(10_000, lat, lon, forecast_time)
assert Map.has_key?(detail.factors, :humidity)
end
test "prefers an exact-time profile over any older fallback" do
exact = ~U[2026-07-15 14:00:00Z]
older = ~U[2026-07-15 10:00:00Z]
lat = 32.75
lon = -97.125
wet = build_profile(dewpoint_c: 20.0)
dry = build_profile(dewpoint_c: -5.0)
# Older (dry) profile also present — must be ignored once the
# exact-time (wet) profile exists.
ProfilesFile.write!(older, %{{lat, lon} => dry})
ProfilesFile.write!(exact, %{{lat, lon} => wet})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: exact, band_mhz: 10_000, score: 70, factors: nil}],
exact
)
detail = Propagation.point_detail(10_000, lat, lon, exact)
wet_humidity = humidity_factor_for(lat, lon, wet, exact, 10_000)
assert detail.factors.humidity == wet_humidity
end
test "returns empty factors when the fallback profile has no cell for this lat/lon" do
# Analysis profile exists for a different cell. The fallback
# timestamp resolves, but read_point at (lat, lon) comes back nil
# on the fallback too — must degrade to `factors: %{}` rather
# than crashing.
analysis_time = ~U[2026-07-15 13:00:00Z]
forecast_time = ~U[2026-07-15 15:00:00Z]
click_lat = 32.75
click_lon = -97.125
far_away_lat = 40.0
far_away_lon = -75.0
ProfilesFile.write!(analysis_time, %{{far_away_lat, far_away_lon} => build_profile()})
Propagation.replace_scores(
[%{lat: click_lat, lon: click_lon, valid_time: forecast_time, band_mhz: 10_000, score: 44, factors: nil}],
forecast_time
)
detail = Propagation.point_detail(10_000, click_lat, click_lon, forecast_time)
assert detail.score == 44
assert detail.factors == %{}
end
test "produces a full factor breakdown for every band when the profile is present" do
valid_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75
lon = -97.125
ProfilesFile.write!(valid_time, %{{lat, lon} => build_profile()})
for band_mhz <- [10_000, 24_000, 47_000, 122_000, 241_000] do
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: valid_time, band_mhz: band_mhz, score: 60, factors: nil}],
valid_time
)
detail = Propagation.point_detail(band_mhz, lat, lon, valid_time)
assert detail, "no point_detail for band #{band_mhz}"
assert is_map(detail.factors)
# Every non-empty factor map carries the same set of weighted
# keys across bands — proves we're not silently returning %{} for
# higher bands during the /map breakdown flow.
for required <- [:humidity, :rain, :pressure, :season, :sky, :refractivity, :wind, :td_depression] do
assert Map.has_key?(detail.factors, required),
"band #{band_mhz} missing factor #{required}"
end
end
end
test "defaults to the latest valid_time when the caller omits one" do
older = ~U[2026-07-15 13:00:00Z]
latest = ~U[2026-07-15 14:00:00Z]
lat = 32.75
lon = -97.125
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: older, band_mhz: 10_000, score: 10, factors: nil}],
older
)
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: latest, band_mhz: 10_000, score: 99, factors: nil}],
latest
)
ProfilesFile.write!(latest, %{{lat, lon} => build_profile()})
detail = Propagation.point_detail(10_000, lat, lon)
assert detail.valid_time == latest
assert detail.score == 99
assert Map.has_key?(detail.factors, :humidity)
end
end
# Build a canonical grid profile for point_detail fallback tests.
# Keyword overrides: :temp_c, :dewpoint_c, :hpbl_m map to surface_* keys.
defp build_profile(overrides \\ []) do
base = %{
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}
]
}
Enum.reduce(overrides, base, fn
{:temp_c, v}, acc -> %{acc | surface_temp_c: v}
{:dewpoint_c, v}, acc -> %{acc | surface_dewpoint_c: v}
{:hpbl_m, v}, acc -> %{acc | hpbl_m: v}
{k, v}, acc -> Map.put(acc, k, v)
end)
end
defp humidity_factor_for(lat, lon, profile, valid_time, band_mhz) do
profile
|> Propagation.score_grid_point(valid_time, lat, lon)
|> Enum.find(fn r -> r.band_mhz == band_mhz end)
|> Map.fetch!(:factors)
|> Map.fetch!(:humidity)
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: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil},
%{lat: 25.125, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 80, factors: nil}
]
Propagation.replace_scores(scores, valid_time)
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 cold read" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil}],
valid_time
)
assert ScoreCache.fetch(10_000, valid_time) == :miss
_ = Propagation.scores_at(10_000, valid_time)
assert {:ok, [%{lat: 25.0, lon: -125.0, score: 75}]} = ScoreCache.fetch(10_000, valid_time)
end
test "returns cached bounds-filtered results without touching the filesystem" do
valid_time = ~U[2026-07-15 13:00:00Z]
# Seed the cache directly — no ScoresFile exists, so if the
# result matches the cached payload we know the disk 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}
])
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
result = Propagation.scores_at(10_000, valid_time, bounds)
assert [%{lat: 32.0, lon: -97.0, score: 75}] = result
end
test "returns empty list when no data anywhere" do
assert Propagation.scores_at(10_000, ~U[2026-07-15 13:00:00Z]) == []
end
# Cache-hit is the map's most frequent LiveView call. The span
# wrapping the disk-read path costs ~2 telemetry handler dispatches
# per call, dominating the actual ~10µs ETS work; skip the span on
# hits and rely on the cache hit/miss counter instead.
test "cache-hit path does not emit the scores_at.stop span" do
valid_time = ~U[2026-07-15 13:00:00Z]
ScoreCache.put(10_000, valid_time, [%{lat: 32.0, lon: -97.0, score: 75}])
test_pid = self()
handler_id = {__MODULE__, :cache_hit_no_span}
:telemetry.attach_many(
handler_id,
[
[:microwaveprop, :propagation, :scores_at, :stop],
[:microwaveprop, :propagation, :scores_at, :cache]
],
fn event, _measurements, metadata, _config ->
send(test_pid, {:telemetry, event, metadata})
end,
nil
)
try do
_ = Propagation.scores_at(10_000, valid_time)
assert_receive {:telemetry, [:microwaveprop, :propagation, :scores_at, :cache], %{hit: true}}
refute_receive {:telemetry, [:microwaveprop, :propagation, :scores_at, :stop], _}, 50
after
:telemetry.detach(handler_id)
end
end
test "cache-miss path still emits the scores_at.stop span" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil}],
valid_time
)
assert ScoreCache.fetch(10_000, valid_time) == :miss
test_pid = self()
handler_id = {__MODULE__, :cache_miss_span}
:telemetry.attach(
handler_id,
[:microwaveprop, :propagation, :scores_at, :stop],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry, event, measurements, metadata})
end,
nil
)
try do
_ = Propagation.scores_at(10_000, valid_time)
assert_receive {:telemetry, [:microwaveprop, :propagation, :scores_at, :stop], _, _}
after
:telemetry.detach(handler_id)
end
end
end
describe "point_forecast/3 with cache" do
# Write a score to both disk and cache. Production always writes
# the store first, then warms the cache — nothing produces a
# cache-only record, so the tests mirror that invariant.
defp seed_point(band_mhz, valid_time, lat, lon, score) do
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: valid_time, band_mhz: band_mhz, score: score, factors: nil}],
valid_time
)
ScoreCache.put(band_mhz, valid_time, [%{lat: lat, lon: lon, score: score}])
end
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)
seed_point(10_000, t1, 32.875, -97.0, 70)
seed_point(10_000, t2, 32.875, -97.0, 75)
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 valid_times older than one hour but keeps recent past as the 'now' anchor" do
# Two hours old: dropped. Thirty minutes old: kept as the
# leftmost "now" point. One hour future: kept.
stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
seed_point(10_000, stale, 32.875, -97.0, 40)
seed_point(10_000, recent, 32.875, -97.0, 60)
seed_point(10_000, future, 32.875, -97.0, 80)
result = Propagation.point_forecast(10_000, 32.875, -97.0)
assert [
%{valid_time: ^recent, score: 60},
%{valid_time: ^future, score: 80}
] = result
end
test "falls back to the single latest valid_time when everything is older than one hour" do
# HRRR just cut out (nothing fresh); we still want the chart to
# render one point at the newest time we have, matching
# available_valid_times semantics.
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second)
seed_point(10_000, older, 32.875, -97.0, 30)
seed_point(10_000, newer, 32.875, -97.0, 45)
result = Propagation.point_forecast(10_000, 32.875, -97.0)
assert [%{valid_time: ^newer, score: 45}] = result
end
test "snaps coordinates to the nearest grid point" do
valid_time = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
seed_point(10_000, valid_time, 32.875, -97.0, 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 data for the point" do
assert Propagation.point_forecast(10_000, 32.875, -97.0) == []
end
end
describe "point_forecast/3 authoritative timeline" do
test "includes hours present on disk but not yet in the cache" do
# Cache has the two earliest valid_times, but a newer forecast
# hour has just landed on disk. The forecast chart should include
# that third hour so it matches the main-map timeline.
t1 = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0})
t2 = DateTime.add(t1, 3600, :second)
t3 = DateTime.add(t1, 2 * 3600, :second)
for {t, score} <- [{t1, 50}, {t2, 60}, {t3, 70}] do
Propagation.replace_scores(
[%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}],
t
)
end
ScoreCache.clear()
ScoreCache.put(10_000, t1, [%{lat: 32.875, lon: -97.0, score: 50}])
ScoreCache.put(10_000, t2, [%{lat: 32.875, lon: -97.0, score: 60}])
result = Propagation.point_forecast(10_000, 32.875, -97.0)
times = Enum.map(result, & &1.valid_time)
assert t3 in times
assert length(result) == 3
end
end
describe "point_forecast/3 from store" do
test "includes recent past valid_times and falls back to the latest when stale" do
# Three .prop files on disk: one 2h old (dropped by cutoff),
# one 30min old (kept as "now"), one 1h future (kept).
stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
Enum.each([{stale, 40}, {recent, 60}, {future, 80}], fn {t, score} ->
Propagation.replace_scores(
[%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}],
t
)
end)
# Bypass the cache so we go through the store-backed path.
ScoreCache.clear()
result = Propagation.point_forecast(10_000, 32.875, -97.0)
assert [
%{valid_time: ^recent, score: 60},
%{valid_time: ^future, score: 80}
] = result
end
test "returns just the latest store entry when every hour is stale" do
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second)
Enum.each([{older, 30}, {newer, 45}], fn {t, score} ->
Propagation.replace_scores(
[%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}],
t
)
end)
ScoreCache.clear()
result = Propagation.point_forecast(10_000, 32.875, -97.0)
assert [%{valid_time: ^newer, score: 45}] = result
end
end
describe "available_valid_times/1" do
test "reads from the on-disk .prop store" do
valid_time = DateTime.truncate(DateTime.utc_now(), :second)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil}],
valid_time
)
assert ScoreCache.valid_times(10_000) == []
assert Propagation.available_valid_times(10_000) == [valid_time]
end
test "caps the forward horizon to 18 hours so stale cycles don't clutter the timeline" do
now = DateTime.truncate(DateTime.utc_now(), :second)
in_range = DateTime.add(now, 3600, :second)
past_hrrr_horizon = DateTime.add(now, 25 * 3600, :second)
Enum.each([{in_range, 70}, {past_hrrr_horizon, 50}], fn {t, score} ->
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}],
t
)
end)
times = Propagation.available_valid_times(10_000)
assert in_range in times
refute past_hrrr_horizon in times
end
test "filters valid_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)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: stale, band_mhz: 10_000, score: 50, factors: nil}],
stale
)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: fresh, band_mhz: 10_000, score: 80, factors: nil}],
fresh
)
assert Propagation.available_valid_times(10_000) == [fresh]
end
test "sees a newly written forecast hour even when the cache is warm with earlier times" do
# Reproduces the race where PropagationGridWorker writes a new .prop
# file but MapLive's propagation_updated handler runs before the
# ScoreCache has absorbed the cache_refresh broadcast. The cached
# view alone is not enough — the timeline must pick up the new file.
t_earlier = ~U[2026-07-15 13:00:00Z]
t_new = ~U[2026-07-15 14:00:00Z]
# Warm the cache with the earlier valid_time only.
ScoreCache.put(10_000, t_earlier, [%{lat: 25.0, lon: -125.0, score: 60}])
# New .prop file lands on disk for the next hour — but ScoreCache
# has not yet received the cache_refresh for `t_new`.
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: t_new, band_mhz: 10_000, score: 85, factors: nil}],
t_new
)
assert t_new in Propagation.available_valid_times(10_000)
end
end
describe "scores_at_fresh/3" do
test "bypasses a stale cache entry and returns the on-disk scores" do
# Reproduces the race where MapLive handles a propagation_updated
# message while the ScoreCache still holds the previous chain's
# scores for the same (band, valid_time). The fresh path must
# always return what's on disk and rewrite the cache accordingly.
valid_time = DateTime.truncate(DateTime.utc_now(), :second)
stale_scores = [%{lat: 25.0, lon: -125.0, score: 10}]
ScoreCache.put(10_000, valid_time, stale_scores)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 95, factors: nil}],
valid_time
)
fresh = Propagation.scores_at_fresh(10_000, valid_time, nil)
assert [%{lat: 25.0, lon: -125.0, score: 95, valid_time: ^valid_time}] = fresh
end
test "refreshes the cache so subsequent scores_at calls see the new data" do
valid_time = DateTime.truncate(DateTime.utc_now(), :second)
ScoreCache.put(10_000, valid_time, [%{lat: 25.0, lon: -125.0, score: 10}])
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 95, factors: nil}],
valid_time
)
_ = Propagation.scores_at_fresh(10_000, valid_time, nil)
assert {:ok, [%{score: 95}]} = ScoreCache.fetch(10_000, valid_time)
end
test "applies bounding-box filtering just like scores_at/3" do
valid_time = DateTime.truncate(DateTime.utc_now(), :second)
Propagation.replace_scores(
[
%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 80, factors: nil},
%{lat: 40.0, lon: -80.0, valid_time: valid_time, band_mhz: 10_000, score: 70, factors: nil}
],
valid_time
)
bounds = %{"south" => 20.0, "north" => 30.0, "west" => -130.0, "east" => -120.0}
fresh = Propagation.scores_at_fresh(10_000, valid_time, bounds)
assert length(fresh) == 1
assert hd(fresh).lat == 25.0
end
end
describe "warm_cache_and_broadcast/2" do
test "loads scores from the on-disk store into the cache" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[
%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil},
%{lat: 25.125, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 80, factors: nil}
],
valid_time
)
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.replace_scores(
[
%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: nil},
%{lat: 25.0, lon: -125.0, valid_time: valid_time, band_mhz: 24_000, score: 30, factors: nil}
],
valid_time
)
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 the most recent valid_time across bands" do
t1 = ~U[2026-07-15 12:00:00Z]
t2 = ~U[2026-07-15 13:00:00Z]
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: t1, band_mhz: 10_000, score: 50, factors: nil}],
t1
)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: t2, band_mhz: 10_000, score: 60, factors: nil}],
t2
)
assert Propagation.latest_valid_time() == t2
end
end
describe "record_run_timing/1" do
test "persists a successful forecast-hour timing" do
run_time = ~U[2026-07-15 12:00:00Z]
started = ~U[2026-07-15 12:00:05Z]
finished = ~U[2026-07-15 12:09:20Z]
assert {:ok, row} =
Propagation.record_run_timing(%{
run_time: run_time,
forecast_hour: 3,
valid_time: DateTime.add(run_time, 3, :hour),
started_at: started,
finished_at: finished,
duration_ms: 555_000,
status: :ok
})
assert row.run_time == run_time
assert row.forecast_hour == 3
assert row.valid_time == ~U[2026-07-15 15:00:00Z]
assert row.duration_ms == 555_000
assert row.status == :ok
assert is_nil(row.error)
end
test "persists a failure with an error message" do
run_time = ~U[2026-07-15 12:00:00Z]
assert {:ok, row} =
Propagation.record_run_timing(%{
run_time: run_time,
forecast_hour: 5,
valid_time: DateTime.add(run_time, 5, :hour),
started_at: ~U[2026-07-15 12:00:00Z],
finished_at: ~U[2026-07-15 12:02:30Z],
duration_ms: 150_000,
status: :failed,
error: "HRRR fetch timeout"
})
assert row.status == :failed
assert row.error == "HRRR fetch timeout"
end
test "rejects duplicate (run_time, forecast_hour) pairs" do
attrs = %{
run_time: ~U[2026-07-15 12:00:00Z],
forecast_hour: 0,
valid_time: ~U[2026-07-15 12:00:00Z],
started_at: ~U[2026-07-15 12:00:00Z],
finished_at: ~U[2026-07-15 12:05:00Z],
duration_ms: 300_000,
status: :ok
}
assert {:ok, _} = Propagation.record_run_timing(attrs)
assert {:error, changeset} = Propagation.record_run_timing(attrs)
assert "has already been taken" in errors_on(changeset).run_time
end
test "requires run_time, forecast_hour, duration_ms, status" do
assert {:error, changeset} = Propagation.record_run_timing(%{})
errors = errors_on(changeset)
assert errors.run_time
assert errors.forecast_hour
assert errors.duration_ms
assert errors.status
end
end
describe "list_recent_run_timings/1" do
test "returns rows ordered by started_at descending, most recent first" do
run_time = ~U[2026-07-15 12:00:00Z]
for fh <- 0..2 do
{:ok, _} =
Propagation.record_run_timing(%{
run_time: run_time,
forecast_hour: fh,
valid_time: DateTime.add(run_time, fh, :hour),
started_at: DateTime.add(run_time, fh * 600, :second),
finished_at: DateTime.add(run_time, fh * 600 + 500, :second),
duration_ms: 500_000,
status: :ok
})
end
rows = Propagation.list_recent_run_timings(limit: 10)
assert length(rows) == 3
assert Enum.map(rows, & &1.forecast_hour) == [2, 1, 0]
end
end
end