Each fix is covered by a regression test that fails on `main` and passes on this commit. Round 1 (initial review): * propagation: thread `latitude` into the conditions map so `score_season/4` actually picks up regional multipliers * hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at a future cycle that NOAA hasn't published yet * radio: spherical-vector great-circle midpoint replaces the arithmetic mean — anti-meridian paths no longer fold to Greenwich * weather: `reconcile_weather_statuses` scales the longitude band by `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude * radio/maidenhead: clamp 90°/180° below the field-bucket overflow so `from_latlon` never emits invalid characters like 'S' * prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native Duct Boost actually fires * propagation/region (Elixir + Rust): inclusive upper bounds so points exactly at lat_max get the regional multiplier * weather/sounding_params (Elixir + Rust): drop the 10 m gradient floor so HRRR's thin near-surface layers stop hiding sharp ducts * weather/sounding_params: when the profile ends inside a duct, finalize it with the highest sample as the top instead of throwing it away (Rust port already correct) Round 2 (post-fix sweep): * radio + commercial: single canonical haversine in Radio (atan2 form); Commercial delegates instead of carrying a second copy that could disagree at threshold distances * prop_grid_rs/profiles_file: `snap_coords` matches Elixir's step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so Rust-keyed and Elixir-keyed profile maps land on the same cell * weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse` uniformly — wgrib2 dropping the trailing `.0` from a longitude no longer crashes the whole chain step
1080 lines
38 KiB
Elixir
1080 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,
|
|
{{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
|