Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
+ `if/else` with a classifier that pattern-matches the error reason
directly, returning `:transient | :permanent`, and dispatch via
`case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
step on the row/col comprehensions so an image box whose centre
lands outside the raster collapses to an empty iteration instead
of firing Elixir 1.19's ambiguous-range warning. Adds a regression
test for the right-edge box.
Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
duct_thickness, best_duct_freq, duct_usable_for_band,
distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
handling; parse_snmpget_output OID normalisation and junk-line
tolerance.
- HrrrNativeClient native_level_count, native_variables,
native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
monotonic in RH, finite across the operating envelope) + idempotent
nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
output; latlon_to_pixel round-trip for every grid cell.
Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.
Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
274 lines
9.1 KiB
Elixir
274 lines
9.1 KiB
Elixir
defmodule Microwaveprop.Propagation.ProfilesFileTest do
|
|
# async: false — tests mutate the global Application env to redirect
|
|
# the scores dir to a private tmp tree.
|
|
use ExUnit.Case, async: false
|
|
|
|
alias Microwaveprop.Propagation.ProfilesFile
|
|
|
|
setup do
|
|
dir =
|
|
Path.join(
|
|
System.tmp_dir!(),
|
|
"profiles_file_test_#{System.unique_integer([:positive])}"
|
|
)
|
|
|
|
File.mkdir_p!(dir)
|
|
|
|
prev = Application.get_env(:microwaveprop, :propagation_scores_dir)
|
|
Application.put_env(:microwaveprop, :propagation_scores_dir, dir)
|
|
|
|
on_exit(fn ->
|
|
File.rm_rf!(dir)
|
|
|
|
if prev do
|
|
Application.put_env(:microwaveprop, :propagation_scores_dir, prev)
|
|
else
|
|
Application.delete_env(:microwaveprop, :propagation_scores_dir)
|
|
end
|
|
end)
|
|
|
|
%{dir: dir}
|
|
end
|
|
|
|
describe "path_for/1" do
|
|
test "nests under base_dir/profiles/<iso>.etf.gz", %{dir: dir} do
|
|
assert ProfilesFile.path_for(~U[2026-04-14 18:00:00Z]) ==
|
|
Path.join([dir, "profiles", "2026-04-14T18:00:00Z.etf.gz"])
|
|
end
|
|
end
|
|
|
|
describe "read/1" do
|
|
test "returns the full grid_data map round-tripped through ETF" do
|
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
|
|
|
grid_data = %{
|
|
{32.75, -97.125} => %{surface_temp_c: 25.0, surface_dewpoint_c: 15.0},
|
|
{33.0, -97.0} => %{surface_temp_c: 26.0}
|
|
}
|
|
|
|
ProfilesFile.write!(valid_time, grid_data)
|
|
|
|
assert {:ok, round_tripped} = ProfilesFile.read(valid_time)
|
|
assert round_tripped[{32.75, -97.125}][:surface_temp_c] == 25.0
|
|
assert round_tripped[{33.0, -97.0}][:surface_temp_c] == 26.0
|
|
end
|
|
|
|
test "returns :enoent when no file exists" do
|
|
assert ProfilesFile.read(~U[2026-04-14 18:00:00Z]) == {:error, :enoent}
|
|
end
|
|
|
|
test "round-trips the full production data shape including duct fields" do
|
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
|
|
|
grid_data = %{
|
|
{32.75, -97.125} => %{
|
|
surface_temp_c: 25.0,
|
|
surface_dewpoint_c: 15.0,
|
|
surface_pressure_mb: 1013.25,
|
|
hpbl_m: 750.0,
|
|
pwat_mm: 32.5,
|
|
run_time: ~U[2026-04-14 17:00:00Z],
|
|
precip_mm: 0.0,
|
|
wind_u: 3.2,
|
|
wind_v: -1.7,
|
|
cloud_cover_pct: 25.0,
|
|
native_min_gradient: -78.4,
|
|
best_duct_freq_ghz: 24.0,
|
|
duct_count: 1,
|
|
max_duct_thickness_m: 200.0,
|
|
ducts: [
|
|
%{base_m: 200.0, top_m: 400.0, thickness_m: 200.0, min_freq_ghz: 10.0}
|
|
],
|
|
profile: [
|
|
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 15.0, "hght" => 50.0}
|
|
]
|
|
}
|
|
}
|
|
|
|
ProfilesFile.write!(valid_time, grid_data)
|
|
|
|
assert {:ok, round_tripped} = ProfilesFile.read(valid_time)
|
|
|
|
point = round_tripped[{32.75, -97.125}]
|
|
assert point[:surface_temp_c] == 25.0
|
|
assert point[:run_time] == ~U[2026-04-14 17:00:00Z]
|
|
assert [duct] = point[:ducts]
|
|
assert duct[:base_m] == 200.0
|
|
assert duct[:thickness_m] == 200.0
|
|
assert duct[:min_freq_ghz] == 10.0
|
|
end
|
|
end
|
|
|
|
describe "list_valid_times/0" do
|
|
test "returns all persisted valid_times sorted ascending" do
|
|
assert ProfilesFile.list_valid_times() == []
|
|
|
|
for vt <- [~U[2026-04-14 18:00:00Z], ~U[2026-04-14 10:00:00Z], ~U[2026-04-14 14:00:00Z]] do
|
|
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
|
|
end
|
|
|
|
assert ProfilesFile.list_valid_times() == [
|
|
~U[2026-04-14 10:00:00Z],
|
|
~U[2026-04-14 14:00:00Z],
|
|
~U[2026-04-14 18:00:00Z]
|
|
]
|
|
end
|
|
end
|
|
|
|
describe "latest_valid_time/0" do
|
|
test "returns the most recent persisted valid_time" do
|
|
assert ProfilesFile.latest_valid_time() == nil
|
|
|
|
for vt <- [~U[2026-04-14 10:00:00Z], ~U[2026-04-14 18:00:00Z], ~U[2026-04-14 14:00:00Z]] do
|
|
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
|
|
end
|
|
|
|
assert ProfilesFile.latest_valid_time() == ~U[2026-04-14 18:00:00Z]
|
|
end
|
|
end
|
|
|
|
describe "write!/2 + read_point/3" do
|
|
test "round-trips a point and snaps lat/lon to the 0.125° grid" do
|
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
|
|
|
grid_data = %{
|
|
{32.75, -97.125} => %{surface_temp_c: 25.0, surface_dewpoint_c: 15.0, wind_u: 3.0, wind_v: -1.0},
|
|
{25.0, -125.0} => %{surface_temp_c: 10.0}
|
|
}
|
|
|
|
ProfilesFile.write!(valid_time, grid_data)
|
|
|
|
# Exact match
|
|
assert %{surface_temp_c: 25.0, wind_u: 3.0} =
|
|
ProfilesFile.read_point(valid_time, 32.75, -97.125)
|
|
|
|
# Nudged within snap tolerance still resolves to the same cell
|
|
assert %{surface_temp_c: 25.0} =
|
|
ProfilesFile.read_point(valid_time, 32.7, -97.1)
|
|
|
|
# A point with no stored profile returns nil
|
|
assert ProfilesFile.read_point(valid_time, 40.0, -80.0) == nil
|
|
end
|
|
|
|
test "returns nil for a missing valid_time" do
|
|
assert ProfilesFile.read_point(~U[2026-04-14 18:00:00Z], 32.75, -97.125) == nil
|
|
end
|
|
end
|
|
|
|
describe "prune_older_than/1" do
|
|
test "deletes files with valid_time strictly before the cutoff" do
|
|
old = ~U[2026-04-14 10:00:00Z]
|
|
keep = ~U[2026-04-14 18:00:00Z]
|
|
|
|
ProfilesFile.write!(old, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
|
|
ProfilesFile.write!(keep, %{{25.0, -125.0} => %{surface_temp_c: 2.0}})
|
|
|
|
assert 1 == ProfilesFile.prune_older_than(~U[2026-04-14 12:00:00Z])
|
|
assert ProfilesFile.read_point(old, 25.0, -125.0) == nil
|
|
assert %{surface_temp_c: 2.0} = ProfilesFile.read_point(keep, 25.0, -125.0)
|
|
end
|
|
end
|
|
|
|
describe "retain_window/2" do
|
|
test "keeps only files whose valid_time is inside [run_time, run_time + max_fh * 3600]" do
|
|
run_time = ~U[2026-04-14 18:00:00Z]
|
|
|
|
inside_f00 = ~U[2026-04-14 18:00:00Z]
|
|
inside_f10 = ~U[2026-04-15 04:00:00Z]
|
|
after_window = ~U[2026-04-15 13:00:00Z]
|
|
before_window = ~U[2026-04-14 10:00:00Z]
|
|
|
|
for vt <- [inside_f00, inside_f10, after_window, before_window] do
|
|
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
|
|
end
|
|
|
|
assert 2 == ProfilesFile.retain_window(run_time, 18)
|
|
assert %{surface_temp_c: 1.0} = ProfilesFile.read_point(inside_f00, 25.0, -125.0)
|
|
assert %{surface_temp_c: 1.0} = ProfilesFile.read_point(inside_f10, 25.0, -125.0)
|
|
assert ProfilesFile.read_point(after_window, 25.0, -125.0) == nil
|
|
assert ProfilesFile.read_point(before_window, 25.0, -125.0) == nil
|
|
end
|
|
end
|
|
|
|
describe "MessagePack dual-format reader" do
|
|
test "read/1 prefers .mp.gz over .etf.gz when both exist", %{dir: _dir} do
|
|
vt = ~U[2026-04-19 14:00:00Z]
|
|
|
|
# Elixir writes the legacy ETF path.
|
|
ProfilesFile.write!(vt, %{{32.9, -97.0} => %{surface_temp_c: 1.0}})
|
|
|
|
# Rust would land a .mp.gz sibling; simulate it.
|
|
mp_body = %{
|
|
"v" => 1,
|
|
"valid_time" => "2026-04-19T14:00:00Z",
|
|
"cells" => [
|
|
%{"lat" => 32.9, "lon" => -97.0, "profile" => %{"surface_temp_c" => 2.0}}
|
|
]
|
|
}
|
|
|
|
mp_path = ProfilesFile.mp_path_for(vt)
|
|
File.mkdir_p!(Path.dirname(mp_path))
|
|
File.write!(mp_path, :zlib.gzip(Msgpax.pack!(mp_body, iodata: false)))
|
|
|
|
# mp.gz wins.
|
|
assert {:ok, grid} = ProfilesFile.read(vt)
|
|
assert grid[{32.9, -97.0}] == %{surface_temp_c: 2.0}
|
|
end
|
|
|
|
test "read/1 atomizes whitelisted keys, leaves others as strings", %{dir: _dir} do
|
|
vt = ~U[2026-04-19 14:00:00Z]
|
|
|
|
mp_body = %{
|
|
"v" => 1,
|
|
"valid_time" => "2026-04-19T14:00:00Z",
|
|
"cells" => [
|
|
%{
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"profile" => %{
|
|
"surface_temp_c" => 22.5,
|
|
"duct_count" => 1,
|
|
"ducts" => [
|
|
%{"base_m" => 100, "top_m" => 200, "thickness_m" => 100, "m_deficit" => 12.3, "min_freq_ghz" => 15.2}
|
|
],
|
|
"unknown_field" => "keep-as-string"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
mp_path = ProfilesFile.mp_path_for(vt)
|
|
File.mkdir_p!(Path.dirname(mp_path))
|
|
File.write!(mp_path, :zlib.gzip(Msgpax.pack!(mp_body, iodata: false)))
|
|
|
|
{:ok, grid} = ProfilesFile.read(vt)
|
|
profile = grid[{32.9, -97.0}]
|
|
|
|
assert profile[:surface_temp_c] == 22.5
|
|
assert profile[:duct_count] == 1
|
|
assert [duct] = profile[:ducts]
|
|
assert duct[:base_m] == 100
|
|
assert duct[:min_freq_ghz] == 15.2
|
|
# Keys outside the whitelist stay strings so they can't crash a
|
|
# later String.to_atom/1 bomb.
|
|
assert profile["unknown_field"] == "keep-as-string"
|
|
end
|
|
|
|
test "read/1 returns :enoent when neither format exists", %{dir: _dir} do
|
|
vt = ~U[2030-01-01 00:00:00Z]
|
|
assert ProfilesFile.read(vt) == {:error, :enoent}
|
|
end
|
|
|
|
test "list_valid_times/0 dedupes when both formats share a timestamp", %{dir: _dir} do
|
|
vt = ~U[2026-04-19 14:00:00Z]
|
|
ProfilesFile.write!(vt, %{{32.9, -97.0} => %{surface_temp_c: 1.0}})
|
|
|
|
mp_body = %{"v" => 1, "valid_time" => "2026-04-19T14:00:00Z", "cells" => []}
|
|
mp_path = ProfilesFile.mp_path_for(vt)
|
|
File.mkdir_p!(Path.dirname(mp_path))
|
|
File.write!(mp_path, :zlib.gzip(Msgpax.pack!(mp_body, iodata: false)))
|
|
|
|
assert ProfilesFile.list_valid_times() == [vt]
|
|
end
|
|
end
|
|
end
|