Item 1 — .sgrid dense binary scalar format replacing chunked gzip+msgpack - New rust/prop_grid_rs/src/sgrid.rs: magic SGRD, same header layout as .pgrid, 20-field cell-major f32 body, write_atomic (tmp+rename, NFS-safe) - New lib/microwaveprop/weather/sgrid.ex: Elixir reader modeled on pgrid.ex (pread single-cell reads, bounds-filtered viewport reads, NaN→nil sentinel) - ScalarFile updated to prefer .sgrid reads, chunked .mp.gz as fallback - Pipeline writes .sgrid alongside existing chunked format (all three paths) Item 2 — HRDPS decode-once + rotated-pole index, restore 0.125° resolution - New rust/prop_grid_rs/src/rotated_pole.rs: CF-convention geographic→rotated transform, OnceLock-cached GDS params parsed from wgrib2 -grid, precomputed Vec<u32> lookup table mapping target cells to native grid indices - Native decode path in decoder.rs: wgrib2 -no_header -order we:sn -bin (raw f32 dump, ~0.32 s/message) + indexing via lookup table - HRDPS_STEP: 0.5° → 0.125° (4× finer, ~57k Canadian cells vs ~3.5k) Item 3 — k8s memory limits: 3Gi → 1.5Gi (per-task grid footprint: ~200-400 MB HashMap → ~18 MB dense planes) Item 4 — CLAUDE.md and profiles_file.ex documentation drift fixed: .pgrid primary, .mp.gz legacy, .sgrid added, write_atomic protocol doc, cleanup gaps reorganized, 'Only f00 is persisted' corrected to f00..f48
444 lines
14 KiB
Elixir
444 lines
14 KiB
Elixir
defmodule Microwaveprop.Weather.Sgrid do
|
||
@moduledoc """
|
||
Reader for the `.sgrid` derived-weather-scalar format written by
|
||
`prop-grid-rs` (`rust/prop_grid_rs/src/sgrid.rs`).
|
||
|
||
Replaces the 5°×5° chunked 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.
|
||
* reading a viewport is one contiguous `pread` per grid row.
|
||
|
||
Same header layout as `Microwaveprop.Propagation.Pgrid` with magic
|
||
`SGRD`. The field table names map 1:1 to the atom keys
|
||
`Microwaveprop.Weather.ScalarFile` already produces, so callers see
|
||
the same map shape regardless of the underlying artifact format.
|
||
|
||
## Layout (little-endian)
|
||
|
||
magic 4 "SGRD"
|
||
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`.
|
||
"""
|
||
|
||
@magic "SGRD"
|
||
@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. Matches
|
||
# `Microwaveprop.Weather.ScalarFile`'s `@atom_keys` whitelist
|
||
# and `prop_grid_rs::sgrid::SCALAR_FIELDS`.
|
||
@field_atoms %{
|
||
"temperature" => :temperature,
|
||
"dewpoint_depression" => :dewpoint_depression,
|
||
"surface_rh" => :surface_rh,
|
||
"surface_pressure_mb" => :surface_pressure_mb,
|
||
"surface_refractivity" => :surface_refractivity,
|
||
"refractivity_gradient" => :refractivity_gradient,
|
||
"bl_height" => :bl_height,
|
||
"pwat" => :pwat,
|
||
"temp_850mb" => :temp_850mb,
|
||
"dewpoint_850mb" => :dewpoint_850mb,
|
||
"temp_700mb" => :temp_700mb,
|
||
"dewpoint_700mb" => :dewpoint_700mb,
|
||
"lapse_rate" => :lapse_rate,
|
||
"mid_lapse_rate" => :mid_lapse_rate,
|
||
"inversion_strength" => :inversion_strength,
|
||
"inversion_base_m" => :inversion_base_m,
|
||
"ducting" => :ducting,
|
||
"duct_base_m" => :duct_base_m,
|
||
"duct_strength" => :duct_strength,
|
||
"duct_cutoff_ghz" => :duct_cutoff_ghz
|
||
}
|
||
|
||
defmodule Header do
|
||
@moduledoc "Parsed `.sgrid` 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
|
||
]
|
||
end
|
||
|
||
@doc "Base directory the scalar store lives under."
|
||
@spec base_dir() :: String.t()
|
||
def base_dir do
|
||
Path.join(
|
||
Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores"),
|
||
"weather_scalars"
|
||
)
|
||
end
|
||
|
||
@doc "Absolute path for the `.sgrid` 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}.sgrid")
|
||
end
|
||
|
||
@doc "HRDPS sibling path — `<base>/weather_scalars/<iso>.hrdps.sgrid`."
|
||
@spec path_for_hrdps(DateTime.t()) :: String.t()
|
||
def path_for_hrdps(%DateTime{} = valid_time) do
|
||
iso = valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||
Path.join(base_dir(), "#{iso}.hrdps.sgrid")
|
||
end
|
||
|
||
@doc "Whether a `.sgrid` exists for `valid_time`."
|
||
@spec exists?(DateTime.t()) :: boolean()
|
||
def exists?(%DateTime{} = valid_time) do
|
||
File.exists?(path_for(valid_time))
|
||
end
|
||
|
||
@doc "Whether an HRDPS `.sgrid` exists for `valid_time`."
|
||
@spec exists_hrdps?(DateTime.t()) :: boolean()
|
||
def exists_hrdps?(%DateTime{} = valid_time) do
|
||
File.exists?(path_for_hrdps(valid_time))
|
||
end
|
||
|
||
@doc """
|
||
Read a single cell's scalar row for `(valid_time, lat, lon)`.
|
||
|
||
Opens the file, `pread`s the header and then one record. Returns `nil`
|
||
when the file is missing, the point falls outside the grid, or the
|
||
cell has no surface temperature.
|
||
"""
|
||
@spec read_point(DateTime.t(), number(), number()) :: map() | nil
|
||
def read_point(%DateTime{} = valid_time, lat, lon) do
|
||
# Try HRRR first, then HRDPS for Canadian cells.
|
||
Enum.find_value(
|
||
[path_for(valid_time), path_for_hrdps(valid_time)],
|
||
&read_point_from_path(&1, lat, lon)
|
||
)
|
||
end
|
||
|
||
defp read_point_from_path(path, lat, lon) do
|
||
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_row(header, bin, lat, lon)
|
||
else
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Read every populated cell as `%{{lat, lon} => row}`.
|
||
|
||
Cells with no surface temperature are skipped. Returns `{:ok, grid}` or
|
||
`{:error, :enoent}`.
|
||
"""
|
||
@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 """
|
||
Read every persisted row for `valid_time` whose lat/lon falls within
|
||
`bounds`. Pass `nil` to read every cell. Returns `[]` if no file
|
||
exists. Merges HRRR + HRDPS, preferring HRRR on overlap.
|
||
"""
|
||
@spec read_bounds(DateTime.t(), ScalarFile.bounds() | nil) :: [ScalarFile.row()]
|
||
def read_bounds(%DateTime{} = valid_time, bounds) do
|
||
hrrr = read_bounds_from(path_for(valid_time), bounds)
|
||
hrdps = read_bounds_from(path_for_hrdps(valid_time), bounds)
|
||
|
||
case {hrrr, hrdps} do
|
||
{[], []} -> []
|
||
{h, []} -> h
|
||
{[], c} -> c
|
||
{h, c} -> merge_prefer_hrrr(h, c)
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Read only the HRDPS sibling rows for `valid_time` within `bounds`.
|
||
Used by `/weather-ca` for Canadian-only views.
|
||
"""
|
||
@spec read_bounds_hrdps(DateTime.t(), ScalarFile.bounds() | nil) :: [ScalarFile.row()]
|
||
def read_bounds_hrdps(%DateTime{} = valid_time, bounds) do
|
||
read_bounds_from(path_for_hrdps(valid_time), bounds)
|
||
end
|
||
|
||
@doc """
|
||
Every `valid_time` with a `.sgrid` 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, ".sgrid"))
|
||
|> Enum.reject(&String.ends_with?(&1, ".hrdps.sgrid"))
|
||
|> Enum.map(&(&1 |> String.replace_suffix(".sgrid", "") |> parse_iso()))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> Enum.sort(DateTime)
|
||
|
||
_ ->
|
||
[]
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Like `list_valid_times/0` but only for HRDPS `.sgrid` files.
|
||
"""
|
||
@spec list_valid_times_hrdps() :: [DateTime.t()]
|
||
def list_valid_times_hrdps do
|
||
case File.ls(base_dir()) do
|
||
{:ok, names} ->
|
||
names
|
||
|> Enum.filter(&String.ends_with?(&1, ".hrdps.sgrid"))
|
||
|> Enum.map(&(&1 |> String.replace_suffix(".hrdps.sgrid", "") |> parse_iso()))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> Enum.sort(DateTime)
|
||
|
||
_ ->
|
||
[]
|
||
end
|
||
end
|
||
|
||
# ── Private ─────────────────────────────────────────────────────────
|
||
|
||
defp read_bounds_from(path, bounds) do
|
||
with {:ok, raw} <- File.read(path),
|
||
{:ok, header} <- parse_header(raw) do
|
||
record_bytes = header.n_fields * 4
|
||
|
||
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))
|
||
|> maybe_filter_bounds(bounds)
|
||
else
|
||
_ -> []
|
||
end
|
||
end
|
||
|
||
defp maybe_filter_bounds(cells, nil), do: cells
|
||
|
||
defp maybe_filter_bounds(cells, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
||
Enum.filter(cells, fn {{lat, lon}, _row} ->
|
||
lat >= s and lat <= n and lon >= w and lon <= e
|
||
end)
|
||
end
|
||
|
||
defp populated_cell(header, {bin, cell}) do
|
||
{lat, lon} = cell_latlon(header, cell)
|
||
|
||
case to_row(header, bin, lat, lon) do
|
||
nil -> []
|
||
row -> [{{lat, lon}, row}]
|
||
end
|
||
end
|
||
|
||
defp merge_prefer_hrrr(hrrr, hrdps) do
|
||
hrrr_keys = MapSet.new(hrrr, fn {{lat, lon}, _row} -> {lat, lon} end)
|
||
extras = Enum.reject(hrdps, fn {{lat, lon}, _row} -> MapSet.member?(hrrr_keys, {lat, lon}) end)
|
||
hrrr ++ extras
|
||
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
|
||
}}
|
||
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
|
||
|
||
# ── Cell addressing ────────────────────────────────────────────────
|
||
|
||
defp record_offset(header, cell), do: header.body_offset + cell * header.n_fields * 4
|
||
|
||
defp 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 — callers treat a
|
||
# missing cell as "no data here".
|
||
defp to_row(header, bin, lat, lon) do
|
||
values = decode_values(bin)
|
||
|
||
case at(values, header, "temperature") do
|
||
nil ->
|
||
nil
|
||
|
||
_ ->
|
||
row =
|
||
header.fields
|
||
|> Enum.with_index()
|
||
|> Enum.reduce(%{lat: lat, lon: lon, valid_time: header.valid_time}, fn {name, idx}, acc ->
|
||
put_field(acc, name, Enum.at(values, idx))
|
||
end)
|
||
|
||
row
|
||
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
|
||
:error ->
|
||
acc
|
||
|
||
{:ok, key} when key == :ducting ->
|
||
Map.put(acc, key, value == 1.0)
|
||
|
||
{:ok, key} ->
|
||
Map.put(acc, key, value)
|
||
end
|
||
end
|
||
|
||
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: []
|
||
end
|