Two related UI changes driven by the same data:
* Move the selected-layer description out of the sidebar and into a
dedicated top-right overlay on the map. The sidebar kept the same
layer buttons but dropped the descriptive text; the new overlay
shows group + layer name + description in one card. Mobile still
shows the description in the collapsible panel since it has no
top-right free real estate.
* Add a forecast-hour timeline bar at the bottom of the map, matching
the propagation map. WeatherMapLive enumerates ProfilesFile on mount
(the grid worker has been persisting f00..f18 on disk since
commit 07ffcf5), pushes data-valid-times for the JS hook to render
as buttons, and handles a new select_time event by reading the
per-hour ProfilesFile on demand via Weather.weather_grid_at/2.
weather_grid_at/2 deliberately skips the GridCache write path for
non-latest hours — caching 18 × 92k rows would add ~300 MB per pod.
A ~2 MB ETF decode per scrub is fast enough for a click.
Subscribes to propagation:pipeline so the timeline picks up new
forecast hours live as they land, without waiting for the full chain
to finish.
151 lines
5.1 KiB
Elixir
151 lines
5.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
|
|
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
|
|
end
|