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, {{ProfilesFile, :_, :_, :_}, :_, :_} ) :ets.match_delete( :microwaveprop_cache, {{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 test "threads latitude into the season factor so regional adjustments fire" do # August on the Gulf coast (lat ~29) gets the +1.15 regional multiplier # and Iowa corn belt (lat ~42) gets the 0.80 multiplier on the same # band/month. If score_grid_point dropped latitude on the floor the two # season factors would be identical because the underlying base score # only depends on `month`. 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, min_refractivity_gradient: -100.0 } valid_time = ~U[2026-08-15 13:00:00Z] gulf = Propagation.score_grid_point(base_profile, valid_time, 29.0, -95.0) iowa = Propagation.score_grid_point(base_profile, valid_time, 42.0, -93.0) gulf_season = Enum.find(gulf, &(&1.band_mhz == 10_000)).factors.season iowa_season = Enum.find(iowa, &(&1.band_mhz == 10_000)).factors.season assert gulf_season > iowa_season 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