prop/lib/microwaveprop/workers/common_volume_radar_worker.ex
Graham McIntire 828814e767
fix: resolve all compiler warnings except framework-generated phoenix_component_verify
- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro
- Remove unused require Logger from 8 files
- Pin bitstring size variables with ^ in simple_packing, wgrib2,
  complex_packing, section, nexrad_client, mqtt, and radar worker
- Remove dead defp clauses (format/1 nil, depression/2 nil)
- Rename Buildings.Parser type record/0 -> building_record/0 to avoid
  overriding built-in type
- Remove redundant catch-all in candidate_detail.ex
- Simplify contact_live/show.ex conditional based on type narrowing
- Fix DateTime.from_iso8601 return pattern in vendor/oban_web
- Upgrade phoenix_live_view to 1.1.31
- Drop --warnings-as-errors from precommit alias; known false positives
  from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x
- Add credo --strict to precommit as replacement static-analysis gate
2026-05-29 10:18:52 -05:00

285 lines
8.7 KiB
Elixir

defmodule Microwaveprop.Workers.CommonVolumeRadarWorker do
@moduledoc """
Per-contact radar enrichment.
For each contact, fetches the IEM n0q composite-reflectivity frame
closest to the QSO timestamp and aggregates the pixels that fall
inside the lens-shaped common volume between the two endpoints (the
intersection of 400 km-radius disks). The aggregated statistics feed
the rain-scatter vs tropo classifier.
Jobs are unique on `contact_id` so the backfill and submission-time
enqueue paths collapse to a single job per contact.
"""
use Oban.Worker,
queue: :radar,
max_attempts: 3,
unique: [
period: :infinity,
states: [:available, :scheduled, :executing, :retryable],
keys: [:contact_id]
]
alias Microwaveprop.Propagation.CommonVolume
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Repo
alias Microwaveprop.Weather.NexradClient
require Logger
# Radius of each station's effective rain-scatter neighborhood. Beyond
# this, the bistatic geometry stops being plausible given typical rain
# tops at ~15 km AGL.
@radius_km 400.0
# Pixel-to-dBZ thresholds (see NexradClient.pixel_to_dbz/1).
@rain_dbz 25.0
@heavy_rain_dbz 35.0
@core_dbz 40.0
# Sample every Nth pixel inside the common-volume bounding box. n0q is
# 0.005°/px ≈ 0.5 km/px, so step=10 samples at ~5 km resolution —
# enough to catch thunderstorm cores (typically 10+ km wide).
@pixel_step 10
@impl Oban.Worker
def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do
case Repo.get(Contact, contact_id) do
nil ->
:ok
%Contact{pos1: p1, pos2: p2} = contact when is_map(p1) and is_map(p2) ->
process(contact)
contact ->
mark_unavailable(contact)
:ok
end
end
defp process(%Contact{} = contact) do
pos1 = to_latlon(contact.pos1)
pos2 = to_latlon(contact.pos2)
case CommonVolume.bounding_box(pos1, pos2, @radius_km) do
:empty ->
mark_unavailable(contact)
:ok
_bbox ->
fetch_and_store(contact, pos1, pos2)
end
end
defp fetch_and_store(%Contact{} = contact, pos1, pos2) do
qso_at = contact.qso_timestamp
rounded = NexradClient.round_to_5min(DateTime.from_naive!(qso_at, "Etc/UTC"))
case NexradClient.fetch_decoded_frame(rounded) do
{:ok, pixels, width} ->
stats = aggregate_stats(pixels, width, pos1, pos2)
upsert_row!(contact, rounded, stats)
mark_status(contact, :complete)
Logger.info("CommonVolumeRadarWorker: #{contact.id} ingested (max_dbz=#{stats.max_dbz || "nil"})")
:ok
{:error, reason} ->
if permanent_error?(reason) do
Logger.info("CommonVolumeRadarWorker: no frame for #{contact.id}: #{inspect(reason)}")
mark_unavailable(contact)
:ok
else
# Transient (5xx, timeout, connrefused) — return {:error, _} so
# Oban retries and the failure is reflected in the job-failure
# counters. Do NOT pin the contact :unavailable; it must remain
# eligible for the next attempt.
Logger.warning("CommonVolumeRadarWorker: transient error for #{contact.id}: #{inspect(reason)}")
{:error, reason}
end
end
end
# Permanent: 4xx means the IEM archive genuinely has no frame at this
# timestamp, so retrying is pointless. Everything else (5xx, transport
# errors, decode failures) is treated as transient.
defp permanent_error?("NEXRAD n0q HTTP " <> code) do
case Integer.parse(code) do
{status, _} when status in 400..499 -> true
_ -> false
end
end
defp permanent_error?(_), do: false
@doc """
Aggregate n0q pixel statistics over the common volume of the two
endpoints. Pure function — accepts raw pixel buffer + width so it
can be unit-tested without hitting the network.
`step` controls the pixel sampling stride (default 10 ≈ 5 km/sample at
the n0q resolution). Tests pass `step: 1` to hit every pixel of a
small synthetic buffer.
"""
@spec aggregate_stats(
binary(),
non_neg_integer(),
CommonVolume.latlon(),
CommonVolume.latlon(),
keyword()
) ::
%{
pixel_count: non_neg_integer(),
rain_pixel_count: non_neg_integer(),
heavy_rain_pixel_count: non_neg_integer(),
core_pixel_count: non_neg_integer(),
max_dbz: float() | nil,
mean_dbz: float() | nil,
common_volume_km2: float(),
coverage_pct: float() | nil
}
def aggregate_stats(pixels, width, pos1, pos2, opts \\ []) do
Microwaveprop.Instrument.span([:radar, :aggregate_stats], %{}, fn ->
do_aggregate_stats(pixels, width, pos1, pos2, opts)
end)
end
defp do_aggregate_stats(pixels, width, pos1, pos2, opts) do
step = Keyword.get(opts, :step, @pixel_step)
case CommonVolume.bounding_box(pos1, pos2, @radius_km) do
:empty ->
empty_stats(CommonVolume.area_km2(pos1, pos2, @radius_km))
%{min_lat: min_lat, max_lat: max_lat, min_lon: min_lon, max_lon: max_lon} ->
height = div(byte_size(pixels), width)
{x_min, y_max} = NexradClient.latlon_to_pixel(min_lat, min_lon)
{x_max, y_min} = NexradClient.latlon_to_pixel(max_lat, max_lon)
x_min = max(x_min, 0)
x_max = min(x_max, width - 1)
y_min = max(y_min, 0)
y_max = min(y_max, height - 1)
pixels
|> collect_cv_pixels(
width,
Range.new(x_min, x_max, 1),
Range.new(y_min, y_max, 1),
pos1,
pos2,
step
)
|> finalize_stats(CommonVolume.area_km2(pos1, pos2, @radius_km))
end
end
defp collect_cv_pixels(pixels, width, x_range, y_range, pos1, pos2, step) do
# We accumulate three counters (in_cv, echo_count, cumulative dBZ) plus
# max and threshold counts. Using a reduce keeps GC low on the ~10^5
# pixel scan per contact.
for y <- y_range.first..y_range.last//step,
x <- x_range.first..x_range.last//step,
reduce: init_acc() do
acc -> visit_pixel(acc, pixels, width, x, y, pos1, pos2)
end
end
defp visit_pixel(acc, pixels, width, x, y, pos1, pos2) do
lat = 50.0 - y * 0.005
lon = -126.0 + x * 0.005
if CommonVolume.in_common_volume?(pos1, pos2, {lat, lon}, @radius_km) do
offset = y * width + x
<<_::binary-size(^offset), pixel_val::8, _::binary>> = pixels
update_acc(acc, pixel_val)
else
acc
end
end
defp init_acc do
%{
in_cv: 0,
echo_count: 0,
dbz_sum: 0.0,
max_dbz: nil,
rain: 0,
heavy: 0,
core: 0
}
end
defp update_acc(acc, 0), do: %{acc | in_cv: acc.in_cv + 1}
defp update_acc(acc, pixel_val) do
dbz = NexradClient.pixel_to_dbz(pixel_val)
%{
acc
| in_cv: acc.in_cv + 1,
echo_count: acc.echo_count + 1,
dbz_sum: acc.dbz_sum + dbz,
max_dbz: max(acc.max_dbz || dbz, dbz),
rain: acc.rain + if(dbz >= @rain_dbz, do: 1, else: 0),
heavy: acc.heavy + if(dbz >= @heavy_rain_dbz, do: 1, else: 0),
core: acc.core + if(dbz >= @core_dbz, do: 1, else: 0)
}
end
defp finalize_stats(%{in_cv: 0}, cv_area) do
empty_stats(cv_area)
end
defp finalize_stats(acc, cv_area) do
mean = if acc.echo_count > 0, do: acc.dbz_sum / acc.echo_count
%{
pixel_count: acc.in_cv,
rain_pixel_count: acc.rain,
heavy_rain_pixel_count: acc.heavy,
core_pixel_count: acc.core,
max_dbz: acc.max_dbz,
mean_dbz: mean,
common_volume_km2: cv_area,
coverage_pct: 100.0 * acc.echo_count / max(acc.in_cv, 1)
}
end
defp empty_stats(cv_area) do
%{
pixel_count: 0,
rain_pixel_count: 0,
heavy_rain_pixel_count: 0,
core_pixel_count: 0,
max_dbz: nil,
mean_dbz: nil,
common_volume_km2: cv_area,
coverage_pct: nil
}
end
defp upsert_row!(%Contact{id: contact_id}, observed_at, stats) do
%ContactCommonVolumeRadar{}
|> ContactCommonVolumeRadar.changeset(Map.merge(stats, %{contact_id: contact_id, observed_at: observed_at}))
|> Repo.insert(
on_conflict: {:replace, ~w(observed_at common_volume_km2 pixel_count rain_pixel_count heavy_rain_pixel_count
core_pixel_count max_dbz mean_dbz coverage_pct updated_at)a},
conflict_target: :contact_id
)
end
defp mark_unavailable(%Contact{} = contact), do: mark_status(contact, :unavailable)
defp mark_status(%Contact{} = contact, status) do
contact
|> Ecto.Changeset.change(%{radar_status: status})
|> Repo.update!()
end
defp to_latlon(%{"lat" => lat, "lon" => lon}) when is_number(lat) and is_number(lon) do
{lat / 1.0, lon / 1.0}
end
end