Persist weather grid for every forecast hour via ProfilesFile

Extend the f00 profile persistence to cover every forecast hour
(f00-f18) so /weather keeps working across pod restarts and shows
forecast-hour atmospheric data without a fresh database. Route the
Weather cold path (latest_grid_valid_time, load_weather_grid,
weather_point_detail) through ProfilesFile first, with the legacy
hrrr_profiles table as a last-resort historical fallback. Warm
GridCache from the latest profile on app start so /weather is hot
the moment a pod boots.
This commit is contained in:
Graham McIntire 2026-04-14 16:30:30 -05:00
parent 2b4756e921
commit 130e062054
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 217 additions and 29 deletions

View file

@ -35,6 +35,11 @@ defmodule Microwaveprop.Application do
# Run migrations after Repo is started
Microwaveprop.Release.migrate()
# Warm GridCache from the latest persisted ProfilesFile so /weather
# has data immediately after a pod restart. Runs after the GridCache
# GenServer is up (it's in `children` above).
Microwaveprop.Weather.warm_grid_cache_from_latest_profile()
# Load ML model in dev/test only (Nx/Axon not compiled for prod)
if Application.get_env(:microwaveprop, :load_ml_model, false) do
Microwaveprop.Propagation.load_ml_model()

View file

@ -62,6 +62,20 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
:ok
end
@doc """
Read the full persisted grid_data for `valid_time`. Returns
`{:ok, %{{lat, lon} => profile}}` or `{:error, :enoent}` if the file
doesn't exist. Used by `Microwaveprop.Weather` to warm `GridCache`
on pod startup without touching the database.
"""
@spec read(DateTime.t()) :: {:ok, %{{float(), float()} => map()}} | {:error, :enoent}
def read(%DateTime{} = valid_time) do
case File.read(path_for(valid_time)) do
{:ok, binary} -> {:ok, :erlang.binary_to_term(binary, [:safe])}
{:error, _} -> {:error, :enoent}
end
end
@doc """
Read a single profile for `(valid_time, lat, lon)`. Returns `nil` if
the file is missing or the point has no profile. Input lat/lon are
@ -69,13 +83,10 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
"""
@spec read_point(DateTime.t(), float(), float()) :: map() | nil
def read_point(%DateTime{} = valid_time, lat, lon) do
case File.read(path_for(valid_time)) do
{:ok, binary} ->
case read(valid_time) do
{:ok, grid_data} ->
{snapped_lat, snapped_lon} = snap(lat, lon)
binary
|> :erlang.binary_to_term([:safe])
|> Map.get({snapped_lat, snapped_lon})
Map.get(grid_data, {snapped_lat, snapped_lon})
{:error, _} ->
nil

View file

@ -3,6 +3,7 @@ defmodule Microwaveprop.Weather do
import Ecto.Query
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Repo
alias Microwaveprop.Weather.Era5Profile
alias Microwaveprop.Weather.GridCache
@ -361,20 +362,26 @@ defmodule Microwaveprop.Weather do
@spec latest_grid_valid_time() :: DateTime.t() | nil
def latest_grid_valid_time do
case GridCache.latest_valid_time() do
%DateTime{} = vt ->
vt
nil ->
Repo.one(
from(h in HrrrProfile,
where: h.is_grid_point == true,
select: max(h.valid_time)
)
)
cond do
vt = GridCache.latest_valid_time() -> vt
vt = ProfilesFile.latest_valid_time() -> vt
true -> latest_grid_valid_time_from_db()
end
end
# Last-resort fallback for historical data sitting in the legacy
# hrrr_profiles table. PropagationGridWorker no longer writes
# grid-point rows there, so in steady state this returns nil and
# the ProfilesFile fallback above is the real source of truth.
defp latest_grid_valid_time_from_db do
Repo.one(
from(h in HrrrProfile,
where: h.is_grid_point == true,
select: max(h.valid_time)
)
)
end
@doc """
Cache-only read for the /weather LiveView mount hot path. Returns whatever
is in `GridCache` for the latest valid_time and fires a deduped background
@ -420,13 +427,24 @@ defmodule Microwaveprop.Weather do
rows
:miss ->
full = load_weather_grid_from_db(latest_vt)
full = load_grid_rows_for(latest_vt)
GridCache.put(latest_vt, full)
filter_weather_bounds(full, bounds)
end
end
end
# Build derived GridCache rows for a valid_time from whichever
# source has data: the persisted ProfilesFile first (hot path in
# steady state), then the legacy hrrr_profiles table (historical
# data only).
defp load_grid_rows_for(valid_time) do
case ProfilesFile.read(valid_time) do
{:ok, grid_data} -> build_grid_cache_rows(grid_data, valid_time)
{:error, _} -> load_weather_grid_from_db(valid_time)
end
end
defp filter_weather_bounds(rows, nil), do: rows
defp filter_weather_bounds(rows, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
@ -495,11 +513,38 @@ defmodule Microwaveprop.Weather do
"""
@spec warm_grid_cache_and_broadcast(DateTime.t()) :: :ok
def warm_grid_cache_and_broadcast(valid_time) do
rows = load_weather_grid_from_db(valid_time)
rows = load_grid_rows_for(valid_time)
GridCache.broadcast_put(valid_time, rows)
:ok
end
@doc """
Warm the local `GridCache` from the latest persisted `ProfilesFile`
on pod startup. Makes `/weather` usable immediately after a deploy
instead of waiting for the next hourly PropagationGridWorker run.
Local put only every node reads the same NFS mount so no need to
broadcast.
"""
@spec warm_grid_cache_from_latest_profile() :: :ok
def warm_grid_cache_from_latest_profile do
case ProfilesFile.latest_valid_time() do
nil ->
:ok
valid_time ->
try do
rows = load_grid_rows_for(valid_time)
GridCache.put(valid_time, rows)
Logger.info("Weather: warmed GridCache from ProfilesFile for #{valid_time} (#{length(rows)} rows)")
rescue
e ->
Logger.warning("Weather: ProfilesFile warm failed: #{inspect(e)}")
end
:ok
end
end
@doc """
Build derived weather grid cache rows directly from the in-memory HRRR
`grid_data` that `PropagationGridWorker` already has after fetching a
@ -566,8 +611,30 @@ defmodule Microwaveprop.Weather do
snapped_lon = Float.round(Float.round(lon / step) * step, 3)
case GridCache.fetch_point(valid_time, snapped_lat, snapped_lon) do
{:ok, row} -> row
:miss -> weather_point_detail_from_db(valid_time, snapped_lat, snapped_lon)
{:ok, row} ->
row
:miss ->
case weather_point_detail_from_profiles(valid_time, snapped_lat, snapped_lon) do
nil -> weather_point_detail_from_db(valid_time, snapped_lat, snapped_lon)
row -> row
end
end
end
# Derive a single GridCache-shaped row from a persisted ProfilesFile
# entry for `(valid_time, lat, lon)`. Returns nil when the file
# doesn't exist or the point has no profile.
defp weather_point_detail_from_profiles(valid_time, snapped_lat, snapped_lon) do
case ProfilesFile.read_point(valid_time, snapped_lat, snapped_lon) do
nil ->
nil
profile ->
case build_grid_cache_rows(%{{snapped_lat, snapped_lon} => profile}, valid_time) do
[row] -> row
_ -> nil
end
end
end

View file

@ -198,13 +198,12 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
:erlang.garbage_collect()
# Persist the fully-enriched f00 grid_data so point_detail can
# rebuild the factor breakdown for a clicked cell by re-running
# the scorer against the stored profile. Forecast hours skip
# factors in compute_scores, so we skip them here too.
if forecast_hour == 0 do
persist_f00_profiles(grid_data, valid_time)
end
# Persist the fully-enriched grid_data for every forecast hour
# so (a) /weather can show forecast-hour data after a pod
# restart and (b) point_detail can rebuild the factor
# breakdown for a clicked cell at any forecast hour by
# re-running the scorer against the stored profile.
persist_profiles(grid_data, valid_time)
# Build weather cache rows from in-memory grid_data — avoids the ~20s
# JSONB round trip that was crashing the worker before f01f18 could run.
@ -236,7 +235,7 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
end
end
defp persist_f00_profiles(grid_data, valid_time) do
defp persist_profiles(grid_data, valid_time) do
timed("profiles", fn ->
try do
ProfilesFile.write!(valid_time, grid_data)

View file

@ -37,6 +37,39 @@ defmodule Microwaveprop.Propagation.ProfilesFileTest do
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 "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]

View file

@ -1,6 +1,7 @@
defmodule Microwaveprop.WeatherGridTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.GridCache
@ -359,6 +360,33 @@ defmodule Microwaveprop.WeatherGridTest do
assert Weather.weather_point_detail(32.15, -97.40, now) == nil
end
test "falls back to ProfilesFile when GridCache is cold and DB has no grid points" do
now = DateTime.truncate(DateTime.utc_now(), :second)
grid_data = %{
{32.125, -97.375} => %{
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 30.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0},
%{"pres" => 500.0, "tmpc" => -15.0, "dwpc" => -25.0, "hght" => 5600.0}
]
}
}
ProfilesFile.write!(now, grid_data)
result = Weather.weather_point_detail(32.15, -97.40, now)
assert result.lat == 32.125
assert result.temperature == 25.0
assert result.surface_pressure_mb == 1013.0
end
test "includes derived upper-air fields and strips raw profile data" do
now = DateTime.truncate(DateTime.utc_now(), :second)
@ -403,6 +431,48 @@ defmodule Microwaveprop.WeatherGridTest do
end
end
describe "latest_grid_valid_time/0 + ProfilesFile fallback" do
test "uses ProfilesFile.latest_valid_time when GridCache and DB are both empty" do
vt = ~U[2026-04-14 18:00:00Z]
ProfilesFile.write!(vt, %{
{32.125, -97.375} => %{surface_temp_c: 20.0, surface_dewpoint_c: 15.0}
})
assert Weather.latest_grid_valid_time() == vt
end
end
describe "load_weather_grid/1 + ProfilesFile fallback" do
test "returns derived rows from ProfilesFile when GridCache is empty" do
vt = ~U[2026-04-14 18:00:00Z]
ProfilesFile.write!(vt, %{
{32.125, -97.375} => %{
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 30.0
},
{32.250, -97.375} => %{
surface_temp_c: 26.0,
surface_dewpoint_c: 19.0,
surface_pressure_mb: 1012.0,
hpbl_m: 600.0,
pwat_mm: 32.0
}
})
bounds = %{"south" => 32.0, "north" => 33.0, "west" => -98.0, "east" => -97.0}
rows = Weather.load_weather_grid(bounds)
assert length(rows) == 2
assert Enum.any?(rows, &(&1.temperature == 25.0))
assert Enum.any?(rows, &(&1.temperature == 26.0))
end
end
defp insert_hrrr_profile(attrs) do
now = DateTime.truncate(DateTime.utc_now(), :second)

View file

@ -37,6 +37,9 @@ defmodule MicrowavepropWeb.ConnCase do
setup tags do
Microwaveprop.DataCase.setup_sandbox(tags)
Microwaveprop.DataCase.reset_score_files()
Microwaveprop.Weather.GridCache.clear()
Microwaveprop.Propagation.ScoreCache.clear()
{:ok, conn: Phoenix.ConnTest.build_conn()}
end