defmodule Microwaveprop.Propagation.PgridTest do @moduledoc """ The `.pgrid` reader is the Elixir half of a format shared with `rust/prop_grid_rs/src/pgrid.rs`. These tests build the binary by hand to the documented layout, which is what pins the two implementations together — if the Rust writer's header or field ordering drifts, the hand-built fixture here still asserts the contract the reader expects. """ # NOT async: the setup swaps `:propagation_scores_dir` in the # application env, which is global. Running concurrently with other # tests lets them observe this suite's temp directory (and lets them # observe each other's), which showed up as cross-suite failures in # ImportLive / ContactEdit / MonitorLive as well as flaky failures here. use ExUnit.Case, async: false alias Microwaveprop.Propagation.Pgrid alias Microwaveprop.Propagation.ProfilesFile @magic "PGRD" @version 1 @field_name_len 32 @scalar_fields ~w( surface_temp_c surface_dewpoint_c surface_pressure_mb hpbl_m pwat_mm wind_u wind_v cloud_cover_pct precip_mm native_min_gradient best_duct_freq_ghz max_duct_thickness_m duct_count nexrad_max_reflectivity_dbz commercial_degradation_db commercial_baseline_dbm commercial_current_dbm commercial_n_links surface_refractivity min_refractivity_gradient ) @levels [1000, 925, 850] setup do dir = Path.join(System.tmp_dir!(), "pgrid_test_#{System.unique_integer([:positive])}") File.mkdir_p!(Path.join(dir, "profiles")) prev = Application.get_env(:microwaveprop, :propagation_scores_dir) Application.put_env(:microwaveprop, :propagation_scores_dir, dir) on_exit(fn -> if prev, do: Application.put_env(:microwaveprop, :propagation_scores_dir, prev), else: Application.delete_env(:microwaveprop, :propagation_scores_dir) File.rm_rf(dir) end) {:ok, dir: dir} end defp field_names do @scalar_fields ++ Enum.flat_map(@levels, fn p -> ["hght_m_#{p}mb", "tmpc_#{p}mb", "dwpc_#{p}mb"] end) end defp nan, do: <<0, 0, 192, 127>> # Build a .pgrid byte-for-byte per the documented layout. `cells` is a # map of cell_index => %{field_name => value}; everything else is NaN. defp build(valid_time, opts, cells) do names = field_names() n_fields = length(names) n_rows = Keyword.fetch!(opts, :n_rows) n_cols = Keyword.fetch!(opts, :n_cols) table = names |> Enum.map(fn n -> n <> String.duplicate(<<0>>, @field_name_len - byte_size(n)) end) |> IO.iodata_to_binary() body = for cell <- 0..(n_rows * n_cols - 1), name <- names, into: <<>> do case cells |> Map.get(cell, %{}) |> Map.get(name) do nil -> nan() v -> <> end end flags = if opts[:hrdps], do: 1, else: 0 unix = DateTime.to_unix(valid_time) lat_start = Keyword.fetch!(opts, :lat_start) lon_start = Keyword.fetch!(opts, :lon_start) header = @magic <> <<@version, flags, n_fields::little-16, unix::little-signed-64, lat_start::little-float-64, lon_start::little-float-64, 0.125::little-float-64, 0.125::little-float-64, n_rows::little-16, n_cols::little-16>> header <> table <> body end defp write!(valid_time, opts, cells) do path = Pgrid.path_for(valid_time) File.mkdir_p!(Path.dirname(path)) File.write!(path, build(valid_time, opts, cells)) path end defp vt, do: ~U[2026-04-19 14:00:00Z] defp grid_opts, do: [n_rows: 2, n_cols: 3, lat_start: 32.0, lon_start: -97.0] defp full_cell do %{ "surface_temp_c" => 22.5, "surface_dewpoint_c" => 17.0, "surface_pressure_mb" => 1013.2, "hpbl_m" => 1500.0, "pwat_mm" => 30.0, "wind_u" => 3.0, "wind_v" => 4.0, "cloud_cover_pct" => 20.0, "precip_mm" => 0.5, "duct_count" => 2.0, "best_duct_freq_ghz" => 18.4, "surface_refractivity" => 320.0, "min_refractivity_gradient" => -150.0, "hght_m_1000mb" => 100.0, "tmpc_1000mb" => 21.0, "dwpc_1000mb" => 16.0, "hght_m_850mb" => 1500.0, "tmpc_850mb" => 12.75, "dwpc_850mb" => 7.0 } end describe "read_point/3" do test "returns the cell's scalars with atom keys" do write!(vt(), grid_opts(), %{0 => full_cell()}) profile = Pgrid.read_point(vt(), 32.0, -97.0) assert profile.surface_temp_c == 22.5 assert profile.surface_dewpoint_c == 17.0 # `.pgrid` stores f32, so values that aren't exactly representable # come back with ~7 significant digits (1013.2 -> 1013.2000122). # The source GRIB2 data is f32 anyway, and HRRR surface pressure is # good to ~0.1 mb, so this is below the noise floor of the input. assert_in_delta profile.surface_pressure_mb, 1013.2, 0.001 assert profile.hpbl_m == 1500.0 assert profile.pwat_mm == 30.0 assert profile.wind_u == 3.0 assert profile.cloud_cover_pct == 20.0 assert profile.surface_refractivity == 320.0 assert profile.min_refractivity_gradient == -150.0 end test "duct_count comes back as an integer, matching the msgpack shape" do write!(vt(), grid_opts(), %{0 => full_cell()}) assert Pgrid.read_point(vt(), 32.0, -97.0).duct_count === 2 end test "rebuilds the pressure-level profile list, skipping absent levels" do write!(vt(), grid_opts(), %{0 => full_cell()}) profile = Pgrid.read_point(vt(), 32.0, -97.0) # 925 mb was never written, so it must not appear. assert [l1000, l850] = profile.profile assert l1000.pres_mb == 1000.0 assert l1000.hght_m == 100.0 assert l1000.tmpc == 21.0 assert l1000.dwpc == 16.0 assert l850.pres_mb == 850.0 assert l850.tmpc == 12.75 end test "a level with height and temperature but no dewpoint omits :dwpc" do cell = Map.drop(full_cell(), ["dwpc_1000mb", "hght_m_850mb", "tmpc_850mb", "dwpc_850mb"]) write!(vt(), grid_opts(), %{0 => cell}) assert [level] = Pgrid.read_point(vt(), 32.0, -97.0).profile assert level.tmpc == 21.0 refute Map.has_key?(level, :dwpc) end test "NaN fields are absent rather than surfacing as NaN" do # Only surface temp written; everything else is the NaN sentinel. write!(vt(), grid_opts(), %{0 => %{"surface_temp_c" => 10.0}}) profile = Pgrid.read_point(vt(), 32.0, -97.0) assert profile.surface_temp_c == 10.0 refute Map.has_key?(profile, :pwat_mm) refute Map.has_key?(profile, :hpbl_m) refute Map.has_key?(profile, :profile) end test "reconstructs the nested commercial_link_degradation map" do cell = Map.merge(full_cell(), %{ "commercial_degradation_db" => 6.5, "commercial_baseline_dbm" => -45.0, "commercial_current_dbm" => -51.5, "commercial_n_links" => 3.0 }) write!(vt(), grid_opts(), %{0 => cell}) assert %{ degradation_db: 6.5, baseline_dbm: -45.0, current_dbm: -51.5, n_links: 3 } = Pgrid.read_point(vt(), 32.0, -97.0).commercial_link_degradation end test "commercial_* fields are not surfaced at the top level" do write!(vt(), grid_opts(), %{0 => full_cell()}) profile = Pgrid.read_point(vt(), 32.0, -97.0) refute Map.has_key?(profile, :commercial_degradation_db) refute Map.has_key?(profile, :commercial_link_degradation) end test "addresses the correct cell for a non-origin coordinate" do # cell 4 => row 1, col 1 => lat 32.125, lon -96.875 write!(vt(), grid_opts(), %{4 => %{"surface_temp_c" => 99.0}}) assert Pgrid.read_point(vt(), 32.125, -96.875).surface_temp_c == 99.0 assert Pgrid.read_point(vt(), 32.0, -97.0) == nil end test "returns nil outside the grid and for a missing file" do write!(vt(), grid_opts(), %{0 => full_cell()}) assert Pgrid.read_point(vt(), 10.0, -97.0) == nil assert Pgrid.read_point(vt(), 32.0, -150.0) == nil assert Pgrid.read_point(~U[2026-04-19 15:00:00Z], 32.0, -97.0) == nil end test "an unpopulated cell reads as nil, not an empty map" do write!(vt(), grid_opts(), %{0 => full_cell()}) assert Pgrid.read_point(vt(), 32.125, -96.875) == nil end end describe "read/1" do test "returns only populated cells, keyed by snapped lat/lon" do write!(vt(), grid_opts(), %{ 0 => %{"surface_temp_c" => 1.0}, 5 => %{"surface_temp_c" => 2.0} }) assert {:ok, grid} = Pgrid.read(vt()) assert map_size(grid) == 2 assert grid[{32.0, -97.0}].surface_temp_c == 1.0 # cell 5 => row 1, col 2 => lat 32.125, lon -96.75 assert grid[{32.125, -96.75}].surface_temp_c == 2.0 end test "returns :enoent for a missing file" do assert Pgrid.read(vt()) == {:error, :enoent} end test "rejects a file with the wrong magic" do path = Pgrid.path_for(vt()) File.mkdir_p!(Path.dirname(path)) bin = build(vt(), grid_opts(), %{0 => full_cell()}) File.write!(path, "XXXX" <> binary_part(bin, 4, byte_size(bin) - 4)) assert Pgrid.read(vt()) == {:error, :enoent} end end describe "header" do test "carries the hrdps flag and grid geometry" do bin = build(vt(), Keyword.put(grid_opts(), :hrdps, true), %{}) assert {:ok, header} = Pgrid.parse_header(bin) assert header.hrdps? assert header.valid_time == vt() assert header.n_rows == 2 assert header.n_cols == 3 assert header.lat_start == 32.0 end test "rejects an unknown version" do bin = build(vt(), grid_opts(), %{}) bumped = binary_part(bin, 0, 4) <> <<99>> <> binary_part(bin, 5, byte_size(bin) - 5) assert Pgrid.parse_header(bumped) == :error end end describe "ProfilesFile integration" do test "read_point/3 prefers .pgrid and returns the same shape" do write!(vt(), grid_opts(), %{0 => full_cell()}) profile = ProfilesFile.read_point(vt(), 32.0, -97.0) assert profile.surface_temp_c == 22.5 # Same nested shape the `.mp.gz` reader produced, so Skew-T and # PathCompute consumers need no change. assert [%{pres_mb: 1000.0, tmpc: 21.0}, %{pres_mb: 850.0}] = profile.profile end test "read_point/3 snaps an off-grid coordinate to the nearest cell" do write!(vt(), grid_opts(), %{0 => full_cell()}) # 31.98 / -96.99 snap to 32.0 / -97.0. assert ProfilesFile.read_point(vt(), 31.98, -96.99).surface_temp_c == 22.5 end test "list_valid_times/0 discovers .pgrid files" do write!(vt(), grid_opts(), %{0 => full_cell()}) write!(~U[2026-04-19 15:00:00Z], grid_opts(), %{0 => full_cell()}) times = ProfilesFile.list_valid_times() assert vt() in times assert ~U[2026-04-19 15:00:00Z] in times end test "retain_window/2 prunes .pgrid files outside the window" do write!(vt(), grid_opts(), %{0 => full_cell()}) write!(~U[2026-04-19 20:00:00Z], grid_opts(), %{0 => full_cell()}) # Keep [14:00, 15:00] — the 20:00 file falls outside. assert ProfilesFile.retain_window(vt(), 1) == 1 assert File.exists?(Pgrid.path_for(vt())) refute File.exists?(Pgrid.path_for(~U[2026-04-19 20:00:00Z])) end end end