prop/lib/microwaveprop/weather/nexrad_client.ex
Graham McIntire c318c3a932
Nudge HRRR scores with live ASOS obs between runs
Adds Microwaveprop.Propagation.AsosNudge: a pure IDW bias-field module
that takes ASOS observations + HRRR profiles and returns re-scored grid
rows for every cell within 250km of a reporting station. Upper-air
fields (min_refractivity_gradient, pwat_mm, hpbl_m, profile, duct
metadata) pass through unchanged so HRRR's signal isn't clobbered.

The old AsosAdjustmentWorker was unwired and buggy — nil'd out ~22% of
the scoring weight and wrote orphan timestamps. Replaced with a slim
worker that queries the latest HRRR valid_time, fetches live ASOS
currents, calls AsosNudge.compute/3, and upserts onto
(lat, lon, valid_time, band_mhz) so nudged values overwrite the HRRR
hour cleanly instead of polluting available_valid_times. After each
upsert it warms ScoreCache and broadcasts propagation:updated so live
/map clients refresh.

Cron hooked up every 10 minutes in config.exs and dev.exs. Also cleaned
up the stale "dev has propagation disabled" note in CLAUDE.md.

13 new AsosNudge unit tests cover: residual computation (co-located,
out-of-grid, nil fields), IDW weighting (single station, far station,
two equidistant stations, nil component handling), upper-air
preservation, and the compute/3 entry point's shape and radius filter.

Drive-by Styler formatting touched a handful of unrelated files from
`mix format`.
2026-04-12 14:27:27 -05:00

414 lines
13 KiB
Elixir

defmodule Microwaveprop.Weather.NexradClient do
@moduledoc """
Fetches and processes IEM CONUS n0q composite reflectivity PNGs.
The n0q product is a palettized 8-bit PNG, 12200 x 5400 pixels,
covering CONUS at 0.005 degrees/pixel (-126W to -65W, 23N to 50N).
Available every 5 minutes from the IEM archive.
Pixel value 0 = no echo; values 1-255 map linearly to roughly
-30 to +95 dBZ. For each point of interest we extract a ~25 km
box (~50x50 pixels) and compute summary statistics.
"""
alias Microwaveprop.Weather.NexradCache
require Logger
# Image geometry
@lon_min -126.0
@lat_max 50.0
@deg_per_pixel 0.005
# Default box half-size in pixels (~25 km at mid-latitudes)
@default_box_half 25
@base_url "https://mesonet.agron.iastate.edu/archive/data"
@doc "Fetch and process one n0q frame. Returns list of observation maps for points of interest."
@spec fetch_frame(DateTime.t(), [{float(), float()}]) :: {:ok, [map()]} | {:error, term()}
def fetch_frame(timestamp, points_of_interest) do
rounded = round_to_5min(timestamp)
url = frame_url(rounded)
req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, [])
case Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do
{:ok, %{status: 200, body: body}} when is_binary(body) ->
process_frame(body, rounded, points_of_interest)
{:ok, %{status: status}} ->
{:error, "NEXRAD n0q HTTP #{status}"}
{:error, reason} ->
{:error, reason}
end
end
@doc "Round a DateTime down to the nearest 5-minute boundary."
@spec round_to_5min(DateTime.t()) :: DateTime.t()
def round_to_5min(%DateTime{} = dt) do
rounded_minute = div(dt.minute, 5) * 5
%{dt | minute: rounded_minute, second: 0, microsecond: {0, 0}}
end
@doc "Build the IEM archive URL for an n0q frame at the given (already-rounded) timestamp."
@spec frame_url(DateTime.t()) :: String.t()
def frame_url(%DateTime{} = dt) do
date_path = Calendar.strftime(dt, "%Y/%m/%d")
file_ts = Calendar.strftime(dt, "%Y%m%d%H%M")
"#{@base_url}/#{date_path}/GIS/uscomp/n0q_#{file_ts}.png"
end
@doc """
Fetch the latest n0q frame and extract all rain cells with reflectivity
above `min_dbz` within `radius_km` of the given point. Returns
`{:ok, [{lat, lon, dbz}]}` or `{:error, reason}`.
Samples every ~5 km (10 pixels) within the bounding box for efficiency.
The decoded PNG pixel buffer is cached in `NexradCache` for the current
5-minute window, so concurrent clicks and repeat lookups skip the HTTP +
PNG decode entirely.
"""
@spec fetch_rain_cells(float(), float(), float(), float()) :: {:ok, [{float(), float(), float()}]} | {:error, term()}
def fetch_rain_cells(lat, lon, radius_km \\ 300.0, min_dbz \\ 25.0) do
now = DateTime.utc_now()
rounded = round_to_5min(now)
case NexradCache.fetch(rounded) do
{:ok, pixels, width} ->
{:ok, extract_rain_cells(pixels, width, lat, lon, radius_km, min_dbz)}
:miss ->
case fetch_and_decode_frame(rounded) do
{:ok, pixels, width} ->
NexradCache.put(rounded, pixels, width)
NexradCache.prune_older_than(DateTime.add(rounded, -600, :second))
{:ok, extract_rain_cells(pixels, width, lat, lon, radius_km, min_dbz)}
error ->
error
end
end
end
defp fetch_and_decode_frame(rounded) do
url = frame_url(rounded)
req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, [])
case Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do
{:ok, %{status: 200, body: body}} when is_binary(body) -> decode_png_to_pixels(body)
{:ok, %{status: status}} -> {:error, "NEXRAD n0q HTTP #{status}"}
{:error, reason} -> {:error, reason}
end
end
defp extract_rain_cells(pixels, width, center_lat, center_lon, radius_km, min_dbz) do
height = div(byte_size(pixels), width)
# Convert radius to approximate pixel count (~0.005 deg/px, ~0.5 km/px at mid-lat)
dlat = radius_km / 111.0
dlon = radius_km / (111.0 * :math.cos(:math.pi() * center_lat / 180.0))
lat_min = center_lat - dlat
lat_max = center_lat + dlat
lon_min = center_lon - dlon
lon_max = center_lon + dlon
{x_min, y_max_px} = latlon_to_pixel(lat_min, lon_min)
{x_max, y_min_px} = latlon_to_pixel(lat_max, lon_max)
x_min = max(x_min, 0)
x_max = min(x_max, width - 1)
y_min_px = max(y_min_px, 0)
y_max_px = min(y_max_px, height - 1)
# Sample every 10 pixels (~5 km) for efficiency
step = 10
for y <- y_min_px..y_max_px//step,
x <- x_min..x_max//step,
offset = y * width + x,
offset >= 0,
offset < byte_size(pixels),
<<_::binary-size(offset), pixel_val::8, _::binary>> = pixels,
pixel_val > 0,
dbz = pixel_to_dbz(pixel_val),
dbz >= min_dbz do
cell_lat = @lat_max - y * @deg_per_pixel
cell_lon = @lon_min + x * @deg_per_pixel
{Float.round(cell_lat, 3), Float.round(cell_lon, 3), Float.round(dbz, 1)}
end
end
@doc """
Convert (lat, lon) to pixel coordinates in the n0q image.
Returns `{x, y}` where x is the column (0 = leftmost = -126W)
and y is the row (0 = topmost = 50N).
"""
@spec latlon_to_pixel(float(), float()) :: {integer(), integer()}
def latlon_to_pixel(lat, lon) do
x = round((lon - @lon_min) / @deg_per_pixel)
y = round((@lat_max - lat) / @deg_per_pixel)
{x, y}
end
@doc """
Convert a pixel value (0-255) to dBZ.
Value 0 = no echo (returns 0.0). Values 1-255 map linearly to
approximately -30 to +95 dBZ.
"""
@spec pixel_to_dbz(integer()) :: float()
def pixel_to_dbz(0), do: 0.0
def pixel_to_dbz(value) when value >= 1 and value <= 255 do
# Linear mapping: 1 -> -30 dBZ, 255 -> 95 dBZ
-30.0 + (value - 1) / 254.0 * 125.0
end
@doc """
Extract summary statistics from a box of pixels centered at (cx, cy).
`pixels` is a flat binary where each byte is one pixel value.
`width` is the image width (number of columns per row).
`half` is the half-size of the extraction box in pixels.
Returns a map with :mean_reflectivity_dbz, :max_reflectivity_dbz,
:texture_variance, and :pixel_count.
"""
@spec extract_box(binary(), integer(), integer(), integer(), integer()) :: map()
def extract_box(pixels, width, cx, cy, half \\ @default_box_half) do
height = div(byte_size(pixels), width)
x_min = max(cx - half, 0)
x_max = min(cx + half, width - 1)
y_min = max(cy - half, 0)
y_max = min(cy + half, height - 1)
# Collect non-zero dBZ values
dbz_values =
for y <- y_min..y_max,
x <- x_min..x_max,
offset = y * width + x,
offset >= 0,
offset < byte_size(pixels),
<<_::binary-size(offset), pixel_val::8, _::binary>> = pixels,
pixel_val > 0 do
pixel_to_dbz(pixel_val)
end
count = length(dbz_values)
compute_box_stats(dbz_values, count)
end
# -- Private --
defp compute_box_stats(_values, 0) do
%{mean_reflectivity_dbz: 0.0, max_reflectivity_dbz: 0.0, texture_variance: 0.0, pixel_count: 0}
end
defp compute_box_stats(values, count) do
mean = Enum.sum(values) / count
max_val = Enum.max(values)
variance = sample_variance(values, mean, count)
%{
mean_reflectivity_dbz: Float.round(mean, 2),
max_reflectivity_dbz: Float.round(max_val, 2),
texture_variance: Float.round(variance, 2),
pixel_count: count
}
end
defp sample_variance(_values, _mean, count) when count < 2, do: 0.0
defp sample_variance(values, mean, count) do
values
|> Enum.map(fn v -> (v - mean) * (v - mean) end)
|> Enum.sum()
|> Kernel./(count - 1)
end
defp process_frame(png_binary, observed_at, points) do
case decode_png_to_pixels(png_binary) do
{:ok, pixels, width} ->
observations =
Enum.map(points, fn {lat, lon} ->
{cx, cy} = latlon_to_pixel(lat, lon)
stats = extract_box(pixels, width, cx, cy)
Map.merge(stats, %{
observed_at: observed_at,
lat: lat,
lon: lon
})
end)
{:ok, observations}
{:error, reason} ->
{:error, reason}
end
end
# Minimal PNG decoder for indexed-color (palette) 8-bit images.
#
# Extracts raw palette index bytes (not RGB), which is what we need
# since the n0q product encodes dBZ as palette index values.
#
# PNG structure:
# 8-byte signature
# Chunks: [length(4) | type(4) | data(length) | crc(4)]
# IHDR chunk has width, height, bit_depth, color_type
# IDAT chunks contain zlib-compressed scanline data
# Each scanline: filter_byte | pixel_data[width]
defp decode_png_to_pixels(png_binary) do
# Validate PNG signature
<<137, 80, 78, 71, 13, 10, 26, 10, rest::binary>> = png_binary
# Parse chunks
chunks = parse_chunks(rest, [])
# Get dimensions from IHDR
[{_type, ihdr_data} | _] = chunks
<<width::32, height::32, _bit_depth::8, _color_type::8, _rest::binary>> = ihdr_data
# Concatenate all IDAT data and decompress
idat_data =
chunks
|> Enum.filter(fn {type, _} -> type == "IDAT" end)
|> Enum.map(fn {_, data} -> data end)
|> IO.iodata_to_binary()
decompressed = :zlib.uncompress(idat_data)
# Undo per-row PNG filters, extract raw pixel index bytes
pixels = unfilter_rows(decompressed, width, height)
{:ok, pixels, width}
rescue
e ->
{:error, "PNG decode failed: #{inspect(e)}"}
end
defp parse_chunks(<<>>, acc), do: Enum.reverse(acc)
defp parse_chunks(<<length::32, type::binary-size(4), rest::binary>>, acc) do
<<data::binary-size(length), _crc::32, remaining::binary>> = rest
parse_chunks(remaining, [{type, data} | acc])
end
# Undo PNG row filters for 8-bit indexed images (1 byte per pixel).
# Returns a flat binary of pixel index values.
defp unfilter_rows(data, width, height) do
# bytes_per_pixel = 1 for indexed color
# Each row is: 1 filter byte + width pixel bytes
stride = width
{rows, _} =
Enum.reduce(1..height, {[], data}, fn _row_idx, {acc, remaining} ->
<<filter::8, row_data::binary-size(stride), rest::binary>> = remaining
prev_row = List.first(acc, :binary.copy(<<0>>, stride))
filtered = apply_filter(filter, row_data, prev_row, 1)
{[filtered | acc], rest}
end)
rows
|> Enum.reverse()
|> IO.iodata_to_binary()
end
# PNG filter types for 8-bit indexed (bpp=1)
defp apply_filter(0, row, _prev, _bpp), do: row
defp apply_filter(1, row, _prev, bpp) do
# Sub: each byte is diff from byte bpp positions to the left
unfilter_sub(row, bpp)
end
defp apply_filter(2, row, prev, _bpp) do
# Up: each byte is diff from byte directly above
unfilter_up(row, prev)
end
defp apply_filter(3, row, prev, bpp) do
# Average: each byte is diff from average of left and above
unfilter_average(row, prev, bpp)
end
defp apply_filter(4, row, prev, bpp) do
# Paeth: each byte uses Paeth predictor
unfilter_paeth(row, prev, bpp)
end
defp unfilter_sub(row, bpp) do
row_bytes = :binary.bin_to_list(row)
{result, _} =
Enum.reduce(row_bytes, {[], 0}, fn byte, {acc, idx} ->
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
val = rem(byte + left, 256)
{acc ++ [val], idx + 1}
end)
:binary.list_to_bin(result)
end
defp unfilter_up(row, prev) do
row_bytes = :binary.bin_to_list(row)
prev_bytes = :binary.bin_to_list(prev)
row_bytes
|> Enum.zip(prev_bytes)
|> Enum.map(fn {a, b} -> rem(a + b, 256) end)
|> :binary.list_to_bin()
end
defp unfilter_average(row, prev, bpp) do
row_bytes = :binary.bin_to_list(row)
prev_bytes = :binary.bin_to_list(prev)
{result, _} =
Enum.reduce(Enum.with_index(row_bytes), {[], prev_bytes}, fn {byte, idx}, {acc, prev_list} ->
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
above = Enum.at(prev_list, idx, 0)
val = rem(byte + div(left + above, 2), 256)
{acc ++ [val], prev_list}
end)
:binary.list_to_bin(result)
end
defp unfilter_paeth(row, prev, bpp) do
row_bytes = :binary.bin_to_list(row)
prev_bytes = :binary.bin_to_list(prev)
{result, _} =
Enum.reduce(Enum.with_index(row_bytes), {[], prev_bytes}, fn {byte, idx}, {acc, prev_list} ->
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
above = Enum.at(prev_list, idx, 0)
upper_left = if idx >= bpp, do: Enum.at(prev_list, idx - bpp, 0), else: 0
pr = paeth_predictor(left, above, upper_left)
val = rem(byte + pr, 256)
{acc ++ [val], prev_list}
end)
:binary.list_to_bin(result)
end
defp paeth_predictor(a, b, c) do
p = a + b - c
pa = abs(p - a)
pb = abs(p - b)
pc = abs(p - c)
cond do
pa <= pb and pa <= pc -> a
pb <= pc -> b
true -> c
end
end
end