prop/lib/microwaveprop/propagation/scores_file.ex
Graham McIntire 07ffcf52d7
Flip map read path to ScoresFile, stop writing grid HRRR profiles
Read-side cutover for the binary scores store and a companion
cleanup that removes the biggest remaining DB write from the hot
path.

Propagation.scores_at/3, available_valid_times/1, latest_valid_time/0,
latest_valid_time/1, earliest_valid_time/1, point_detail/4, and
point_forecast/3 all now prefer ScoresFile and fall back to the
propagation_scores table when a file is missing. The map render
path reads from /data/scores first; Postgres stays as a safety
net while dual-write is on. point_detail still pulls factors from
Postgres (analysis-hour rows only) and coalesces nil to an empty
map so the JS popup iterates cleanly.

replace_scores/2 is now gated by a postgres_writes_enabled? flag
(runtime env MICROWAVEPROP_SCORES_POSTGRES=false, or the
:propagation_scores_postgres app env key) so the binary-only path
can be benchmarked locally without the DB insert. Default stays
true.

PropagationGridWorker no longer calls store_hrrr_profiles —
persisting 92k grid rows × 19 forecast hours of JSONB profiles
was ~12 min of wall time per chain for a table only
AsosAdjustmentWorker read from. Per-contact HRRR enrichment
through HrrrFetchWorker still writes its own (is_grid_point:
false) rows. AsosAdjustmentWorker is disabled in all three cron
configs since its data source is gone.

DataCase resets the scores tree between tests so per-test
ScoresFile writes don't leak across cases, and ScoresFileTest
switches to async: false because it mutates the global
:propagation_scores_dir env.
2026-04-14 14:50:43 -05:00

384 lines
13 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.Propagation.ScoresFile do
@moduledoc """
Binary on-disk storage for propagation score grids. One file per
`(band_mhz, valid_time)` tuple, ~93 KB each. Written in the
forecast-hour hot path as a companion to the Postgres upsert, and
eventually replacing it.
## Layout
magic : 4 bytes "NTMS"
version : 1 byte 0x01
band_mhz : 4 bytes uint32 little-endian
valid_time : 8 bytes int64 unix seconds little-endian
lat_min : 4 bytes float32 little-endian
lon_min : 4 bytes float32 little-endian
step_deg : 4 bytes float32 little-endian
n_rows : 2 bytes uint16 little-endian (lat dimension)
n_cols : 2 bytes uint16 little-endian (lon dimension)
scores : n_rows × n_cols bytes (uint8, 0-100 or 255 = no-data)
A cell at `(lat, lon)` is at byte offset
`row * n_cols + col` in the scores body, where
`row = (lat - lat_min) / step` and `col = (lon - lon_min) / step`
(both rounded to the nearest integer).
## Atomic writes
Files are written through `rename(2)`: `path.tmp.<uniq>` → fsync →
rename to the final path. On the same NFSv4 filesystem the rename
is atomic, so a concurrent reader either sees the old file, the
new file, or the absence of either — never a partial file.
"""
alias Microwaveprop.Propagation.Grid
require Logger
@magic "NTMS"
@version 1
@no_data 255
@doc "Returns the root directory score files are written to."
@spec base_dir() :: String.t()
def base_dir do
Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores")
end
@doc "Returns the full path for a `(band_mhz, valid_time)` score file."
@spec path_for(non_neg_integer(), DateTime.t()) :: String.t()
def path_for(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
iso = valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
Path.join([base_dir(), Integer.to_string(band_mhz), "#{iso}.ntms"])
end
@doc """
Write a score grid to disk for `(band_mhz, valid_time)`. `scores`
must be a list of `%{lat, lon, score}` maps. Missing cells are
stored as the no-data sentinel (255).
"""
@spec write!(non_neg_integer(), DateTime.t(), [map()]) :: :ok
def write!(band_mhz, %DateTime{} = valid_time, scores) when is_integer(band_mhz) and is_list(scores) do
path = path_for(band_mhz, valid_time)
File.mkdir_p!(Path.dirname(path))
binary = encode(band_mhz, valid_time, scores)
tmp = path <> ".tmp." <> unique_suffix()
File.write!(tmp, binary, [:binary])
File.rename!(tmp, path)
:ok
end
@doc """
Read a score grid from disk. Returns:
* `{:ok, %{band_mhz, valid_time, lat_min, lon_min, step, n_rows, n_cols, scores}}`
where `scores` is the raw binary (row-major, uint8 per cell)
* `{:error, :enoent}` when the file doesn't exist
* `{:error, :invalid_format}` when the file isn't a score grid
"""
@spec read(non_neg_integer(), DateTime.t()) ::
{:ok, map()} | {:error, :enoent | :invalid_format}
def read(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
case File.read(path_for(band_mhz, valid_time)) do
{:ok, binary} -> decode(binary)
{:error, :enoent} -> {:error, :enoent}
{:error, other} -> {:error, other}
end
end
@doc """
Delete every score file whose `valid_time` is strictly before
`cutoff`. Returns the number of files deleted. Foreign files in the
scores tree (anything that doesn't parse as `<iso>.ntms`) are left
alone.
"""
@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_score_files()
|> Enum.reduce(0, fn {path, valid_time_unix}, acc ->
if valid_time_unix < cutoff_unix do
_ = File.rm(path)
acc + 1
else
acc
end
end)
end
@doc """
List every `valid_time` that has a score file on disk for
`band_mhz`, ordered ascending. Empty list if the band directory
doesn't exist yet.
"""
@spec list_valid_times(non_neg_integer()) :: [DateTime.t()]
def list_valid_times(band_mhz) when is_integer(band_mhz) do
dir = Path.join(base_dir(), Integer.to_string(band_mhz))
case File.ls(dir) do
{:ok, files} ->
files
|> Enum.map(&parse_valid_time_dt/1)
|> Enum.reject(&is_nil/1)
|> Enum.sort(DateTime)
_ ->
[]
end
end
@doc """
Return the most recent `valid_time` across all bands on disk, or
`nil` if the scores tree is empty.
"""
@spec latest_valid_time() :: DateTime.t() | nil
def latest_valid_time do
case File.ls(base_dir()) do
{:ok, band_dirs} ->
band_dirs
|> Enum.flat_map(&band_times_or_empty/1)
|> max_datetime()
_ ->
nil
end
end
defp band_times_or_empty(band_dir) do
case Integer.parse(band_dir) do
{band_mhz, ""} -> list_valid_times(band_mhz)
_ -> []
end
end
@doc """
Read the file for `(band_mhz, valid_time)` and return every
scored cell as a list of `%{lat, lon, score}` maps. Optional
`bounds` map (`%{"north", "south", "east", "west"}` or
`%{north:, south:, east:, west:}`) filters the return to cells
inside that box. Cells with the no-data sentinel are excluded.
Returns `[]` when the file doesn't exist or is unreadable —
callers should treat this as "no scores available for this
band/time yet" rather than an error.
"""
@spec read_bounds(non_neg_integer(), DateTime.t(), map() | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer()}]
def read_bounds(band_mhz, %DateTime{} = valid_time, bounds \\ nil) do
case read(band_mhz, valid_time) do
{:ok, payload} -> extract_points(payload, bounds)
_ -> []
end
end
@doc """
Fetch the score for a single grid cell from the file for
`(band_mhz, valid_time)`. Returns `nil` if the file is missing or
if the cell is outside the grid / carries the no-data sentinel.
"""
@spec read_point(non_neg_integer(), DateTime.t(), float(), float()) :: non_neg_integer() | nil
def read_point(band_mhz, %DateTime{} = valid_time, lat, lon) do
case read(band_mhz, valid_time) do
{:ok, payload} -> point_score(payload, lat, lon)
_ -> nil
end
end
# ── Encoding ────────────────────────────────────────────────────
defp encode(band_mhz, valid_time, scores) do
%{lat_min: lat_min, lat_max: lat_max, lon_min: lon_min, lon_max: lon_max} = Grid.bounds()
step = Grid.step()
n_rows = round((lat_max - lat_min) / step) + 1
n_cols = round((lon_max - lon_min) / step) + 1
lookup =
Map.new(scores, fn s ->
{{Float.round(s.lat, 3), Float.round(s.lon, 3)}, s.score}
end)
body = build_body(lookup, lat_min, lon_min, step, n_rows, n_cols)
valid_time_unix = valid_time |> DateTime.truncate(:second) |> DateTime.to_unix()
<<
@magic::binary,
@version::unsigned-8,
band_mhz::unsigned-little-32,
valid_time_unix::signed-little-64,
lat_min::float-little-32,
lon_min::float-little-32,
step::float-little-32,
n_rows::unsigned-little-16,
n_cols::unsigned-little-16,
body::binary
>>
end
defp build_body(lookup, lat_min, lon_min, step, n_rows, n_cols) do
for row <- 0..(n_rows - 1),
col <- 0..(n_cols - 1),
into: <<>> do
lat = Float.round(lat_min + row * step, 3)
lon = Float.round(lon_min + col * step, 3)
<<score_byte(Map.get(lookup, {lat, lon}))::unsigned-8>>
end
end
defp score_byte(nil), do: @no_data
defp score_byte(s) when is_integer(s) and s >= 0 and s <= 100, do: s
defp score_byte(s) when is_integer(s) and s > 100, do: 100
defp score_byte(s) when is_integer(s) and s < 0, do: 0
defp score_byte(_), do: @no_data
# ── Decoding ────────────────────────────────────────────────────
defp decode(
<<@magic, @version::unsigned-8, band_mhz::unsigned-little-32, valid_time_unix::signed-little-64,
lat_min::float-little-32, lon_min::float-little-32, step::float-little-32, n_rows::unsigned-little-16,
n_cols::unsigned-little-16, body::binary>>
) do
expected = n_rows * n_cols
if byte_size(body) == expected do
case DateTime.from_unix(valid_time_unix) do
{:ok, valid_time} ->
{:ok,
%{
band_mhz: band_mhz,
valid_time: valid_time,
lat_min: lat_min,
lon_min: lon_min,
step: step,
n_rows: n_rows,
n_cols: n_cols,
scores: body
}}
_ ->
{:error, :invalid_format}
end
else
{:error, :invalid_format}
end
end
defp decode(_), do: {:error, :invalid_format}
# ── Read helpers ────────────────────────────────────────────────
defp extract_points(%{scores: body} = payload, bounds) do
%{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols} = payload
{row_lo, row_hi, col_lo, col_hi} = bounds_to_index_range(payload, bounds)
for row <- row_lo..row_hi,
col <- col_lo..col_hi,
byte = :binary.at(body, row * n_cols + col),
byte != @no_data do
# guard against the ignored variables warning
_ = n_rows
lat = Float.round(lat_min + row * step, 3)
lon = Float.round(lon_min + col * step, 3)
%{lat: lat, lon: lon, score: byte}
end
end
defp bounds_to_index_range(payload, nil) do
{0, payload.n_rows - 1, 0, payload.n_cols - 1}
end
defp bounds_to_index_range(payload, bounds) do
%{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols} = payload
south = fetch_bound(bounds, :south, "south", lat_min)
north = fetch_bound(bounds, :north, "north", lat_min + (n_rows - 1) * step)
west = fetch_bound(bounds, :west, "west", lon_min)
east = fetch_bound(bounds, :east, "east", lon_min + (n_cols - 1) * step)
row_lo = clamp(trunc((south - lat_min) / step), 0, n_rows - 1)
row_hi = clamp(ceil((north - lat_min) / step), 0, n_rows - 1)
col_lo = clamp(trunc((west - lon_min) / step), 0, n_cols - 1)
col_hi = clamp(ceil((east - lon_min) / step), 0, n_cols - 1)
{min(row_lo, row_hi), max(row_lo, row_hi), min(col_lo, col_hi), max(col_lo, col_hi)}
end
defp fetch_bound(bounds, atom_key, string_key, default) do
case Map.get(bounds, atom_key) || Map.get(bounds, string_key) do
nil -> default
v when is_number(v) -> v
v when is_binary(v) -> String.to_float(v)
end
end
defp clamp(n, lo, _hi) when n < lo, do: lo
defp clamp(n, _lo, hi) when n > hi, do: hi
defp clamp(n, _lo, _hi), do: n
defp point_score(payload, lat, lon) do
%{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols, scores: body} = payload
row = round((lat - lat_min) / step)
col = round((lon - lon_min) / step)
if row in 0..(n_rows - 1) and col in 0..(n_cols - 1) do
case :binary.at(body, row * n_cols + col) do
@no_data -> nil
score -> score
end
end
end
defp max_datetime([]), do: nil
defp max_datetime(times), do: Enum.max(times, DateTime)
defp parse_valid_time_dt(filename) do
with [_, iso] <- Regex.run(~r/^(.+)\.ntms$/, filename),
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
dt
else
_ -> nil
end
end
# ── Prune helpers ───────────────────────────────────────────────
defp list_score_files(root) do
case File.ls(root) do
{:ok, entries} ->
for entry <- entries,
band_path = Path.join(root, entry),
File.dir?(band_path),
file <- files_in(band_path),
valid_time_unix = parse_valid_time(file),
valid_time_unix != nil do
{Path.join(band_path, file), valid_time_unix}
end
_ ->
[]
end
end
defp files_in(dir) do
case File.ls(dir) do
{:ok, entries} -> entries
_ -> []
end
end
defp parse_valid_time(filename) do
with [_, iso] <- Regex.run(~r/^(.+)\.ntms$/, filename),
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
DateTime.to_unix(dt)
else
_ -> nil
end
end
defp unique_suffix do
"#{System.system_time(:nanosecond)}.#{:erlang.unique_integer([:positive])}"
end
end