prop/lib/microwaveprop/weather/scalar_file.ex
Graham McIntire a14f14485e
perf(weather): serve /weather from chunked scalar artifacts
Move the /weather render path off raw HRRR profile decoding + per-cell
SoundingParams + WeatherLayers derivation. The dominant cost was
re-deriving the same scalar fields on every viewport pan and timeline
scrub.

Server side:

- New Microwaveprop.Weather.ScalarFile persists pre-derived scalar
  rows on NFS, bucketed into 5°×5° chunk files under
  <base>/weather_scalars/<iso>/<lat_band>_<lon_band>.etf.gz. Viewport
  reads only decode the chunks that overlap the requested bounds;
  point-detail clicks read exactly one chunk.
- weather_grid_at/2 and weather_point_detail/3 prefer ScalarFile;
  ProfilesFile is now the cold-start fallback only.
- warm_grid_cache_and_broadcast/1 and warm_grid_cache_from_latest_profile/0
  also persist the scalar artifact, and the cold-derive path kicks
  off a per-valid_time-locked async materialization so the next
  reader gets the cheap path.
- NotifyListener.handle_propagation_ready/1 fires
  Weather.materialize_scalar_file/1 in a detached Task on the Rust
  pipeline's NOTIFY propagation_ready, so steady-state forecast hours
  arrive with their scalar artifact already on disk.
- available_weather_valid_times/0 unions ScalarFile + ProfilesFile so
  the timeline survives an aggressive retention sweep on either side.

Browser side (finding #8):

- Mount the legend Leaflet control once and patch its inner content
  on layer changes instead of remove + re-add on every renderLayer.
- renderTimeline now keys off the rendered button set: selection-only
  updates take an applyTimelineSelection patch path that restyles
  existing buttons in place. Full innerHTML rebuild only fires when
  timelineData itself changes.

Also fixes a credo nesting-depth flag in tile_renderer.ex by extracting
interpolate_pair/2 from the inner anonymous function.
2026-04-28 17:15:36 -05:00

304 lines
8.9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Weather.ScalarFile do
@moduledoc """
On-disk store for the per-cell *derived* weather rows that feed `/weather`.
One directory per `valid_time`, with rows bucketed into 5°×5° chunk files
so viewport reads only decode the chunks that overlap the requested bounds.
This is the cheap-read sibling of `Microwaveprop.Propagation.ProfilesFile`:
* `ProfilesFile` keeps the raw HRRR profile per cell (~10 MB decoded) and
is the source of truth for advanced diagnostics, terrain analysis, and
point-detail breakdowns.
* `ScalarFile` keeps only the scalar fields the weather map renders
(temperature, dewpoint depression, refractivity, lapse rates, duct
summary, etc.), already pushed through `WeatherLayers.derive/1`.
Because the scalar shape is small and the derivation has already happened,
a tile request can read just the chunks it needs and skip the
`SoundingParams.derive/1` + `WeatherLayers.derive/1` work entirely.
## Layout
<base_dir>/weather_scalars/
<iso>/ # e.g. 2026-04-28T12:00:00Z/
<lat_band>_<lon_band>.etf.gz
`lat_band = floor(lat / 5)`, `lon_band = floor(lon / 5)`. Each chunk is
the compressed ETF of `[row_map, ...]`. Writes go through
`rename(2)` → atomic on NFS.
## Chunk size
×5° was picked so the typical /weather viewport (a US state at z=6-7)
overlaps 1-4 chunks. Smaller chunks would balloon directory entries on
NFS without measurable read benefit.
"""
require Logger
@chunk_step 5
@subdir "weather_scalars"
@type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => term()}
@type bounds :: %{optional(String.t()) => number()}
@spec base_dir() :: String.t()
def base_dir do
Path.join(
Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores"),
@subdir
)
end
@spec dir_for(DateTime.t()) :: String.t()
def dir_for(%DateTime{} = valid_time) do
Path.join(base_dir(), iso_key(valid_time))
end
@spec exists?(DateTime.t()) :: boolean()
def exists?(%DateTime{} = valid_time) do
case File.ls(dir_for(valid_time)) do
{:ok, [_ | _]} -> true
_ -> false
end
end
@doc """
Persist `rows` for `valid_time`. Rows are bucketed by 5°×5° chunk; each
chunk is written atomically via tmp + rename. Existing chunks for the
same `valid_time` are removed first so a smaller follow-up write doesn't
leave stale chunks behind.
"""
@spec write!(DateTime.t(), [row()]) :: :ok
def write!(%DateTime{} = valid_time, rows) when is_list(rows) do
dir = dir_for(valid_time)
File.mkdir_p!(dir)
# Drop any pre-existing chunks for this valid_time so this write is
# the canonical state — otherwise a smaller follow-up write would
# leave stale chunks behind.
case File.ls(dir) do
{:ok, entries} ->
for entry <- entries do
_ = File.rm(Path.join(dir, entry))
end
_ ->
:ok
end
rows
|> Enum.group_by(&chunk_key/1)
|> Enum.each(fn {{lat_band, lon_band}, chunk_rows} ->
path = Path.join(dir, "#{lat_band}_#{lon_band}.etf.gz")
binary = :erlang.term_to_binary(chunk_rows, [:compressed])
tmp = path <> ".tmp." <> unique_suffix()
File.write!(tmp, binary, [:binary])
File.rename!(tmp, path)
end)
:ok
end
@doc """
Read every persisted row for `valid_time` whose lat/lon falls within
`bounds`. Pass `nil` to read every chunk. Returns `[]` if no scalar
file exists for `valid_time`.
"""
@spec read_bounds(DateTime.t(), bounds() | nil) :: [row()]
def read_bounds(%DateTime{} = valid_time, bounds) do
case list_chunk_files(valid_time) do
[] ->
[]
files ->
files
|> Enum.filter(fn {key, _path} -> chunk_intersects_bounds?(key, bounds) end)
|> Enum.flat_map(fn {_key, path} -> decode_chunk(path) end)
|> filter_bounds(bounds)
end
end
@doc """
Look up a single derived row at `(valid_time, lat, lon)`. Returns
`{:ok, row}` or `:miss` when either the chunk file is absent or the
cell isn't present.
"""
@spec read_point(DateTime.t(), float(), float()) :: {:ok, row()} | :miss
def read_point(%DateTime{} = valid_time, lat, lon) when is_number(lat) and is_number(lon) do
key = {chunk_band(lat * 1.0), chunk_band(lon * 1.0)}
path = Path.join(dir_for(valid_time), chunk_filename(key))
if File.exists?(path) do
find_point_in_chunk(path, snap(lat), snap(lon))
else
:miss
end
end
defp find_point_in_chunk(path, snapped_lat, snapped_lon) do
case Enum.find(decode_chunk(path), fn r ->
r.lat == snapped_lat and r.lon == snapped_lon
end) do
nil -> :miss
row -> {:ok, row}
end
end
@doc """
Every persisted valid_time, sorted ascending. Used to back the /weather
forecast timeline once scalars take over from raw profiles as the
primary read.
"""
@spec list_valid_times() :: [DateTime.t()]
def list_valid_times do
case File.ls(base_dir()) do
{:ok, entries} ->
times =
for entry <- entries,
{:ok, dt, _} <- [DateTime.from_iso8601(entry)],
do: dt
Enum.sort(times, DateTime)
_ ->
[]
end
end
@doc "Delete scalar dirs whose valid_time is strictly before `cutoff`. Returns count removed."
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
def prune_older_than(%DateTime{} = cutoff) do
cutoff_unix = DateTime.to_unix(cutoff)
base_dir()
|> list_valid_time_dirs()
|> Enum.reduce(0, fn {path, dt}, acc ->
if DateTime.to_unix(dt) < cutoff_unix do
_ = File.rm_rf(path)
acc + 1
else
acc
end
end)
end
@doc """
Keep only scalar dirs whose valid_time is inside the closed window
`[run_time, run_time + max_forecast_hour * 3600]`. Mirrors
`ProfilesFile.retain_window/2`.
"""
@spec retain_window(DateTime.t(), non_neg_integer()) :: non_neg_integer()
def retain_window(%DateTime{} = run_time, max_forecast_hour) when max_forecast_hour >= 0 do
lo = DateTime.to_unix(run_time)
hi = lo + max_forecast_hour * 3600
base_dir()
|> list_valid_time_dirs()
|> Enum.reduce(0, fn {path, dt}, acc ->
unix = DateTime.to_unix(dt)
if unix < lo or unix > hi do
_ = File.rm_rf(path)
acc + 1
else
acc
end
end)
end
# ---------- Internal ----------
defp iso_key(%DateTime{} = valid_time) do
valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
end
defp chunk_key(%{lat: lat, lon: lon}) do
{chunk_band(lat * 1.0), chunk_band(lon * 1.0)}
end
defp chunk_band(value) when is_float(value) do
(value / @chunk_step) |> Float.floor() |> trunc()
end
defp chunk_filename({lat_band, lon_band}), do: "#{lat_band}_#{lon_band}.etf.gz"
defp chunk_intersects_bounds?(_key, nil), do: true
defp chunk_intersects_bounds?({lat_band, lon_band}, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
chunk_south = lat_band * @chunk_step
chunk_north = chunk_south + @chunk_step
chunk_west = lon_band * @chunk_step
chunk_east = chunk_west + @chunk_step
chunk_north >= s and chunk_south <= n and chunk_east >= w and chunk_west <= e
end
defp filter_bounds(rows, nil), do: rows
defp filter_bounds(rows, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
Enum.filter(rows, fn %{lat: lat, lon: lon} ->
lat >= s and lat <= n and lon >= w and lon <= e
end)
end
defp decode_chunk(path) do
case File.read(path) do
{:ok, binary} ->
:erlang.binary_to_term(binary)
{:error, reason} ->
Logger.warning("ScalarFile chunk read failed path=#{path} reason=#{inspect(reason)}")
[]
end
end
defp list_chunk_files(%DateTime{} = valid_time) do
dir = dir_for(valid_time)
case File.ls(dir) do
{:ok, entries} ->
for entry <- entries,
key = parse_chunk_filename(entry),
key != nil,
do: {key, Path.join(dir, entry)}
_ ->
[]
end
end
defp parse_chunk_filename(filename) do
with [_, lat_str, lon_str] <- Regex.run(~r/^(-?\d+)_(-?\d+)\.etf\.gz$/, filename),
{lat_band, ""} <- Integer.parse(lat_str),
{lon_band, ""} <- Integer.parse(lon_str) do
{lat_band, lon_band}
else
_ -> nil
end
end
defp list_valid_time_dirs(base) do
case File.ls(base) do
{:ok, entries} ->
for entry <- entries,
{:ok, dt, _} <- [DateTime.from_iso8601(entry)],
do: {Path.join(base, entry), dt}
_ ->
[]
end
end
# Match ProfilesFile's snap step (0.125°) so a click at any lat/lon
# rounds to the same key the writer used.
defp snap(value) do
step = 0.125
Float.round(Float.round(value / step) * step, 3)
end
defp unique_suffix do
"#{System.system_time(:nanosecond)}.#{:erlang.unique_integer([:positive])}"
end
end