Reworks the post-fetch half of the propagation pipeline. Fetch and GRIB2
decode were already cheap — measured against a live HRRR cycle, all 39
pressure messages decode via `wgrib2 -lola` in 0.29 s and the 31 MB
byte-range fetch takes ~3 s — so nothing here touches the decoder. All the
cost was downstream.
Also fixes a broken NOTIFY that made every chain step run up to 5 times.
pg_notify
`NOTIFY propagation_ready, $1` is a Postgres syntax error: NOTIFY is a
utility statement whose payload must be a literal, so a bind raises
42601. It shared a transaction with the `status='done'` UPDATE, so every
successful step rolled back, stayed 'running', and was requeued by
reclaim_stale_running up to @max_reclaim_attempts times. Elixir's
NotifyListener never fired either, so ScoreCache warm and the
"propagation:updated" fan-out were dead.
FieldGrid
A decoded grid was HashMap<(i32,i32), HashMap<Arc<str>, f32>> — a dense
rectangular grid stored as ~95k nested hash maps, costing ~4.6M inserts
on decode, ~3.7M on merge and ~14M lookups across three derivation
passes. wgrib2 -lola already emits one dense row-major f32 block per
message, so keep it: dense per-message planes, names hashed once per
grid into plane ids, NaN as the missing sentinel. This is what forced
PROP_GRID_RS_PARALLELISM=1 under a 3Gi limit.
Fused pass
Three 95k-cell derivation passes plus 23 band-major scoring passes over
a staged Vec<(f64,f64,Conditions,BandInvariants)> (~19MB re-streamed 23
times) collapse into one pass: levels extracted once per cell, all 23
bands scored while the cell is hot, scores accumulated cell-major so
rayon chunks own disjoint slices. Scores land straight in the dense
score-file body — no ScorePoint scatter.
.pgrid
The profile artifact was an rmpv tree plus gzip -9, written 30x an hour,
and ProfilesFile.read_point/3 gunzipped and unpacked the entire 95k-cell
file to return one cell on every map click and Skew-T load. Replaced
with a dense cell-major f32 record array carrying a self-describing
field table. Elixir reads it via :file.pread; .mp.gz and .etf.gz remain
readable so files written before this drain out of the 48h window.
Measured on a full CONUS grid (95,073 cells x 48 planes x 23 bands):
derive + score + build artifacts 0.022 s
profile write 3.957 s -> 0.006 s (22.0 MB -> 22.4 MB on disk)
single-cell read whole-file decode -> 0.5 us
23 score files 0.003 s
Also
- hrrr_points: batched UNNEST upsert replacing one awaited INSERT per
point. Keeps ON CONFLICT DO UPDATE — the PSKR sampler's two-pass loop
depends on it.
- fetcher: real semaphore capping in-flight ranges at
MAX_PARALLEL_RANGES, which the comment claimed but the code did not do
(it spawned all 27 while the connection pool was sized for 8).
- metrics: per-stage histogram. Only chain-step and decode durations
were instrumented, which is why the write cost stayed invisible.
- profiles_file: parse_valid_time anchors on the known extension set, so
sibling-suffixed names like <iso>.hrdps.prop no longer parse as
<iso>.hrdps and vanish from prune and list operations.
- PROP_GRID_RS_PARALLELISM 1 -> 3. Memory limit held at 3Gi until RSS is
observed at the new parallelism.
- cargo fmt over the crate; worker.rs, hrdps_fetcher.rs and nexrad.rs
were already unformatted at HEAD and the pre-commit hook gates on it.
HRDPS still runs at 0.5 degrees. wgrib2 -lola scales linearly in output
points on rotated lat/lon (12.5 s wall, 202 s CPU for one message at
0.125 degrees) because it has no inverse projection for those grids; a raw
native dump is 0.32 s. The fix is decode-once plus a closed-form
rotated-pole index, left for a follow-up.
427 lines
14 KiB
Elixir
427 lines
14 KiB
Elixir
defmodule Microwaveprop.Propagation.Pgrid do
|
|
@moduledoc """
|
|
Reader for the `.pgrid` profile format written by `prop-grid-rs`
|
|
(`rust/prop_grid_rs/src/pgrid.rs`).
|
|
|
|
Replaces the gzipped-MessagePack `.mp.gz` artifact. The format is a
|
|
dense, **cell-major** `f32` record array on a fixed grid, so:
|
|
|
|
* reading one cell is a single `:file.pread/3` of `n_fields * 4`
|
|
bytes at a computed offset — no decompression, no term building.
|
|
The `.mp.gz` path gunzipped, `Msgpax.unpack`ed and atomized the
|
|
*entire* 95k-cell file to answer one point lookup, on every `/map`
|
|
click and every Skew-T load.
|
|
* reading a viewport is one contiguous `pread` per grid row.
|
|
|
|
Layout (little-endian) — see the Rust module for the authoritative
|
|
description:
|
|
|
|
magic 4 "PGRD"
|
|
version 1 0x01
|
|
flags 1 bit0: 0 = hrrr, 1 = hrdps
|
|
n_fields 2 u16
|
|
valid_time 8 i64 unix seconds
|
|
lat_start 8 f64
|
|
lon_start 8 f64
|
|
lat_step 8 f64
|
|
lon_step 8 f64
|
|
n_rows 2 u16
|
|
n_cols 2 u16
|
|
field_table n_fields * 32 (NUL-padded ASCII)
|
|
body n_rows*n_cols*n_fields * 4 f32, cell-major
|
|
|
|
`NaN` is the missing-value sentinel. Erlang's float binary match
|
|
rejects NaN, so `decode_f32/1` maps it to `nil` — which is exactly
|
|
what the `.mp.gz` reader produced for an absent key.
|
|
|
|
Callers get the same map shape `ProfilesFile.read_point/3` always
|
|
returned: atom keys, a `:profile` list of per-level maps, and a
|
|
nested `:commercial_link_degradation` map.
|
|
"""
|
|
|
|
alias Microwaveprop.Propagation.Grid
|
|
|
|
@magic "PGRD"
|
|
@version 1
|
|
@field_name_len 32
|
|
@fixed_header_len 4 + 1 + 1 + 2 + 8 + 8 + 8 + 8 + 8 + 2 + 2
|
|
|
|
# On-disk field name => the atom key callers expect.
|
|
#
|
|
# Written as literal atoms so they exist at compile time. The `.mp.gz`
|
|
# reader used `String.to_existing_atom/1` behind a whitelist, which
|
|
# only worked as long as some *other* module happened to have mentioned
|
|
# each atom — `:wind_v` in particular did not, so that approach raised
|
|
# at runtime. An explicit map removes the hidden dependency, and a
|
|
# field the reader doesn't know (e.g. one added later on the Rust side)
|
|
# is simply skipped instead of crashing or growing the atom table.
|
|
@field_atoms %{
|
|
"surface_temp_c" => :surface_temp_c,
|
|
"surface_dewpoint_c" => :surface_dewpoint_c,
|
|
"surface_pressure_mb" => :surface_pressure_mb,
|
|
"hpbl_m" => :hpbl_m,
|
|
"pwat_mm" => :pwat_mm,
|
|
"wind_u" => :wind_u,
|
|
"wind_v" => :wind_v,
|
|
"cloud_cover_pct" => :cloud_cover_pct,
|
|
"precip_mm" => :precip_mm,
|
|
"native_min_gradient" => :native_min_gradient,
|
|
"best_duct_freq_ghz" => :best_duct_freq_ghz,
|
|
"max_duct_thickness_m" => :max_duct_thickness_m,
|
|
"duct_count" => :duct_count,
|
|
"nexrad_max_reflectivity_dbz" => :nexrad_max_reflectivity_dbz,
|
|
"surface_refractivity" => :surface_refractivity,
|
|
"min_refractivity_gradient" => :min_refractivity_gradient
|
|
}
|
|
|
|
# Deliberately absent from @field_atoms: the commercial_* fields are
|
|
# folded into the nested :commercial_link_degradation map, and the
|
|
# per-level hght_m_/tmpc_/dwpc_ triples are rebuilt into :profile.
|
|
|
|
# Surfaced as an integer, as the msgpack writer did.
|
|
@integer_fields ~w(duct_count)
|
|
|
|
defmodule Header do
|
|
@moduledoc "Parsed `.pgrid` header."
|
|
|
|
@type t :: %__MODULE__{}
|
|
|
|
defstruct [
|
|
:hrdps?,
|
|
:valid_time,
|
|
:n_fields,
|
|
:fields,
|
|
:field_index,
|
|
:lat_start,
|
|
:lon_start,
|
|
:lat_step,
|
|
:lon_step,
|
|
:n_rows,
|
|
:n_cols,
|
|
:body_offset,
|
|
:levels
|
|
]
|
|
end
|
|
|
|
@doc "Base directory the profile store lives under."
|
|
@spec base_dir() :: String.t()
|
|
def base_dir do
|
|
Path.join(
|
|
Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores"),
|
|
"profiles"
|
|
)
|
|
end
|
|
|
|
@doc "Absolute path for the `.pgrid` file covering `valid_time`."
|
|
@spec path_for(DateTime.t()) :: String.t()
|
|
def path_for(%DateTime{} = valid_time) do
|
|
iso = valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
|
Path.join(base_dir(), "#{iso}.pgrid")
|
|
end
|
|
|
|
@doc "Whether a `.pgrid` exists for `valid_time`."
|
|
@spec exists?(DateTime.t()) :: boolean()
|
|
def exists?(%DateTime{} = valid_time), do: File.exists?(path_for(valid_time))
|
|
|
|
@doc """
|
|
Read a single cell's profile for `(valid_time, lat, lon)`.
|
|
|
|
Opens the file, `pread`s the header and then one record. Returns `nil`
|
|
when the file is missing or the point falls outside the grid.
|
|
"""
|
|
@spec read_point(DateTime.t(), number(), number()) :: map() | nil
|
|
def read_point(%DateTime{} = valid_time, lat, lon) do
|
|
path = path_for(valid_time)
|
|
|
|
with {:ok, fd} <- :file.open(path, [:read, :binary, :raw]),
|
|
{:ok, header} <- read_header(fd) do
|
|
result = read_cell(fd, header, lat, lon)
|
|
:file.close(fd)
|
|
result
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp read_cell(fd, header, lat, lon) do
|
|
with cell when is_integer(cell) <- cell_index(header, lat, lon),
|
|
size = header.n_fields * 4,
|
|
{:ok, bin} <- :file.pread(fd, record_offset(header, cell), size),
|
|
true <- byte_size(bin) == size do
|
|
to_profile(header, bin)
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Read every populated cell as `%{{lat, lon} => profile}`.
|
|
|
|
Used for the cold `GridCache` warm in `Microwaveprop.Weather.Grid`.
|
|
Cells with no surface temperature are skipped, so the result matches
|
|
what the `.mp.gz` reader produced (it only ever contained scored cells).
|
|
"""
|
|
@spec read(DateTime.t()) :: {:ok, %{{float(), float()} => map()}} | {:error, :enoent}
|
|
def read(%DateTime{} = valid_time) do
|
|
path = path_for(valid_time)
|
|
|
|
with {:ok, raw} <- File.read(path),
|
|
{:ok, header} <- parse_header(raw) do
|
|
record_bytes = header.n_fields * 4
|
|
|
|
grid =
|
|
raw
|
|
|> binary_part(header.body_offset, byte_size(raw) - header.body_offset)
|
|
|> chunk_records(record_bytes)
|
|
|> Enum.with_index()
|
|
|> Enum.flat_map(&populated_cell(header, &1))
|
|
|> Map.new()
|
|
|
|
{:ok, grid}
|
|
else
|
|
_ -> {:error, :enoent}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Every `valid_time` with a `.pgrid` on disk, sorted ascending.
|
|
"""
|
|
@spec list_valid_times() :: [DateTime.t()]
|
|
def list_valid_times do
|
|
case File.ls(base_dir()) do
|
|
{:ok, names} ->
|
|
names
|
|
|> Enum.filter(&String.ends_with?(&1, ".pgrid"))
|
|
|> Enum.map(&(&1 |> String.replace_suffix(".pgrid", "") |> parse_iso()))
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.sort(DateTime)
|
|
|
|
_ ->
|
|
[]
|
|
end
|
|
end
|
|
|
|
defp populated_cell(header, {bin, cell}) do
|
|
case to_profile(header, bin) do
|
|
nil -> []
|
|
profile -> [{cell_latlon(header, cell), profile}]
|
|
end
|
|
end
|
|
|
|
defp parse_iso(str) do
|
|
case DateTime.from_iso8601(str) do
|
|
{:ok, dt, _} -> dt
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# ── Header ─────────────────────────────────────────────────────────
|
|
|
|
defp read_header(fd) do
|
|
with {:ok, fixed} <- :file.pread(fd, 0, @fixed_header_len),
|
|
{:ok, n_fields} <- peek_n_fields(fixed),
|
|
{:ok, table} <- :file.pread(fd, @fixed_header_len, n_fields * @field_name_len) do
|
|
parse_header(fixed <> table)
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp peek_n_fields(<<@magic, @version, _flags::8, n_fields::little-16, _rest::binary>>), do: {:ok, n_fields}
|
|
|
|
defp peek_n_fields(_), do: :error
|
|
|
|
@doc false
|
|
@spec parse_header(binary()) :: {:ok, Header.t()} | :error
|
|
def parse_header(
|
|
<<@magic, @version, flags::8, n_fields::little-16, valid_unix::little-signed-64, lat_start::little-float-64,
|
|
lon_start::little-float-64, lat_step::little-float-64, lon_step::little-float-64, n_rows::little-16,
|
|
n_cols::little-16, rest::binary>>
|
|
) do
|
|
table_len = n_fields * @field_name_len
|
|
|
|
if byte_size(rest) < table_len do
|
|
:error
|
|
else
|
|
fields =
|
|
rest
|
|
|> binary_part(0, table_len)
|
|
|> chunk_records(@field_name_len)
|
|
|> Enum.map(&trim_nul/1)
|
|
|
|
field_index = fields |> Enum.with_index() |> Map.new()
|
|
|
|
{:ok,
|
|
%Header{
|
|
hrdps?: Bitwise.band(flags, 1) == 1,
|
|
valid_time: DateTime.from_unix!(valid_unix),
|
|
n_fields: n_fields,
|
|
fields: fields,
|
|
field_index: field_index,
|
|
lat_start: lat_start,
|
|
lon_start: lon_start,
|
|
lat_step: lat_step,
|
|
lon_step: lon_step,
|
|
n_rows: n_rows,
|
|
n_cols: n_cols,
|
|
body_offset: @fixed_header_len + table_len,
|
|
levels: level_slots(fields, field_index)
|
|
}}
|
|
end
|
|
end
|
|
|
|
def parse_header(_), do: :error
|
|
|
|
defp trim_nul(bin) do
|
|
case :binary.match(bin, <<0>>) do
|
|
{pos, _} -> binary_part(bin, 0, pos)
|
|
:nomatch -> bin
|
|
end
|
|
end
|
|
|
|
# Pressure levels present in the field table, as
|
|
# {pres_mb, hght_idx, tmpc_idx, dwpc_idx}, ordered by the table.
|
|
defp level_slots(fields, field_index) do
|
|
fields
|
|
|> Enum.flat_map(fn name ->
|
|
case Regex.run(~r/^hght_m_(\d+)mb$/, name) do
|
|
[_, p] -> [String.to_integer(p)]
|
|
_ -> []
|
|
end
|
|
end)
|
|
|> Enum.map(fn p ->
|
|
{p, field_index["hght_m_#{p}mb"], field_index["tmpc_#{p}mb"], field_index["dwpc_#{p}mb"]}
|
|
end)
|
|
end
|
|
|
|
# ── Cell addressing ────────────────────────────────────────────────
|
|
|
|
defp record_offset(header, cell), do: header.body_offset + cell * header.n_fields * 4
|
|
|
|
@doc false
|
|
@spec cell_index(Header.t(), number(), number()) :: non_neg_integer() | nil
|
|
def cell_index(header, lat, lon) do
|
|
row = round((lat - header.lat_start) / header.lat_step)
|
|
col = round((lon - header.lon_start) / header.lon_step)
|
|
|
|
if row >= 0 and col >= 0 and row < header.n_rows and col < header.n_cols do
|
|
row * header.n_cols + col
|
|
end
|
|
end
|
|
|
|
defp cell_latlon(header, cell) do
|
|
row = div(cell, header.n_cols)
|
|
col = rem(cell, header.n_cols)
|
|
|
|
{Float.round(header.lat_start + row * header.lat_step, 3), Float.round(header.lon_start + col * header.lon_step, 3)}
|
|
end
|
|
|
|
# ── Record decoding ────────────────────────────────────────────────
|
|
|
|
# Returns nil for a cell with no surface temperature — the `.mp.gz`
|
|
# file only ever contained scored cells, so callers already treat a
|
|
# missing cell as "no data here".
|
|
defp to_profile(header, bin) do
|
|
values = decode_values(bin)
|
|
|
|
case at(values, header, "surface_temp_c") do
|
|
nil ->
|
|
nil
|
|
|
|
_ ->
|
|
header.fields
|
|
|> Enum.with_index()
|
|
|> Enum.reduce(%{}, fn {name, idx}, acc ->
|
|
put_field(acc, name, Enum.at(values, idx))
|
|
end)
|
|
|> put_commercial(values, header)
|
|
|> put_levels(values, header)
|
|
end
|
|
end
|
|
|
|
defp at(values, header, name) do
|
|
case Map.fetch(header.field_index, name) do
|
|
{:ok, idx} -> Enum.at(values, idx)
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp put_field(acc, _name, nil), do: acc
|
|
|
|
defp put_field(acc, name, value) do
|
|
case Map.fetch(@field_atoms, name) do
|
|
# Unknown field (level triple, commercial_*, or one added on the
|
|
# Rust side after this reader was written) — handled elsewhere or
|
|
# deliberately ignored.
|
|
:error -> acc
|
|
{:ok, key} when name in @integer_fields -> Map.put(acc, key, trunc(value))
|
|
{:ok, key} -> Map.put(acc, key, value)
|
|
end
|
|
end
|
|
|
|
defp put_commercial(acc, values, header) do
|
|
case at(values, header, "commercial_degradation_db") do
|
|
nil ->
|
|
acc
|
|
|
|
degradation ->
|
|
Map.put(acc, :commercial_link_degradation, %{
|
|
degradation_db: degradation,
|
|
baseline_dbm: at(values, header, "commercial_baseline_dbm") || 0.0,
|
|
current_dbm: at(values, header, "commercial_current_dbm") || 0.0,
|
|
n_links: trunc(at(values, header, "commercial_n_links") || 0)
|
|
})
|
|
end
|
|
end
|
|
|
|
defp put_levels(acc, values, header) do
|
|
levels = Enum.flat_map(header.levels, &build_level(&1, values))
|
|
|
|
if levels == [], do: acc, else: Map.put(acc, :profile, levels)
|
|
end
|
|
|
|
defp build_level({pres_mb, hght_idx, tmpc_idx, dwpc_idx}, values) do
|
|
do_build_level(
|
|
pres_mb,
|
|
Enum.at(values, hght_idx),
|
|
Enum.at(values, tmpc_idx),
|
|
dwpc_idx && Enum.at(values, dwpc_idx)
|
|
)
|
|
end
|
|
|
|
# TMP and HGT are both required, matching the Rust-side filter; a level
|
|
# missing either is dropped rather than surfaced half-populated.
|
|
defp do_build_level(_pres_mb, nil, _tmpc, _dwpc), do: []
|
|
defp do_build_level(_pres_mb, _hght, nil, _dwpc), do: []
|
|
|
|
defp do_build_level(pres_mb, hght, tmpc, nil), do: [%{pres_mb: pres_mb * 1.0, hght_m: hght, tmpc: tmpc}]
|
|
|
|
defp do_build_level(pres_mb, hght, tmpc, dwpc), do: [%{pres_mb: pres_mb * 1.0, hght_m: hght, tmpc: tmpc, dwpc: dwpc}]
|
|
|
|
defp decode_values(bin), do: bin |> chunk_records(4) |> Enum.map(&decode_f32/1)
|
|
|
|
# NaN is the missing-value sentinel. Erlang's float binary match
|
|
# rejects NaN and Inf outright, so the fallback clause is what turns
|
|
# "absent" into nil.
|
|
defp decode_f32(<<v::float-little-32>>), do: v
|
|
defp decode_f32(_), do: nil
|
|
|
|
defp chunk_records(bin, size) when byte_size(bin) >= size do
|
|
for <<chunk::binary-size(^size) <- bin>>, do: chunk
|
|
end
|
|
|
|
defp chunk_records(_bin, _size), do: []
|
|
|
|
@doc """
|
|
Snap a lat/lon to the propagation grid step. Kept in sync with
|
|
`Microwaveprop.Propagation.ProfilesFile.snap/2` so a coordinate written
|
|
by either side is reachable from the other.
|
|
"""
|
|
@spec snap(number(), number()) :: {float(), float()}
|
|
def snap(lat, lon), do: {snap_one(lat), snap_one(lon)}
|
|
|
|
defp snap_one(coord) do
|
|
step = Grid.step()
|
|
Float.round(Float.round(coord / step) * step, 3)
|
|
end
|
|
end
|