prop/lib/microwaveprop/weather/nexrad_client.ex
Graham McIntire 250709a1b2 Add more caching to make the map feel instant
- ScoreCache stores {band, valid_time} as %{{lat, lon} => score} map so
  point lookups are O(1); adds fetch_point/4 and valid_times/1
- available_valid_times/1 reads directly from ScoreCache when warm,
  falls back to DB on cold start
- point_forecast/3 iterates cached valid_times and uses fetch_point/4
  instead of hitting the DB per click
- NexradCache: node-local ETS cache of decoded n0q PNG pixel buffers
  keyed by 5-minute rounded timestamp; skips ~1-5s HTTP+decode on
  concurrent/repeat clicks within the same window
- MapLive: start_async the rain_scatter fetch so point_detail renders
  immediately with a pending marker; push rain_scatter_update when
  NEXRAD resolves
- MapLive: preload all 18 remaining forecast hours for the current
  viewport after mount/band change/propagation_updated; client caches
  them and renders timeline scrubs instantly without a server roundtrip.
  Adds set_selected_time event for fast-path state sync.
- Propagation map JS: forecastCache map + drawScatterMarkers helper,
  timeline click uses preloaded cache when available
2026-04-12 12:26:25 -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.
"""
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
alias Microwaveprop.Weather.NexradCache
@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