prop/lib/microwaveprop/workers/asos_adjustment_worker.ex
Graham McIntire ed67efb256
Add MRMS rain mosaic, fix beacons crash, fix UTC clock flash
MRMS
----
Layer the NOAA MRMS PrecipRate product onto the score grid so rain fade
updates every 2 minutes instead of every hour alongside HRRR. New modules:

- Microwaveprop.Weather.MrmsClient: fetches the latest .grib2.gz off the
  NCEP mirror (Req auto-decompresses so no gunzip step), writes the raw
  GRIB2 to a temp file, and calls the existing wgrib2 wrapper with the
  0.125 propagation grid spec to get interpolated cells. Returns a
  %{{lat, lon} => mm_per_hour} map with missing-value sentinels dropped.

- Microwaveprop.Weather.MrmsCache: ETS-backed GenServer mirroring
  ScoreCache/GridCache. Caches a single "current" entry keyed by
  valid_time with PubSub broadcast so peer nodes stay in sync and only
  the Oban leader pays the fetch + regrid cost.

- Microwaveprop.Workers.MrmsFetchWorker: cron every 2 minutes, short-
  circuits when the cached valid_time already matches the newest file.

Microwaveprop.Propagation.AsosNudge.compute/4 now takes an optional
rain_grid. When a cell has MRMS rain >= 0.1 mm/hr it gets patched onto
the HRRR profile's `precip_mm` field (the scorer already reads it there)
and the cell is re-scored even with no ASOS station nearby. Cells with
MRMS rain below the threshold aren't touched so dry cells keep their
raw HRRR scores (which have the wind/sky/native-gradient signal that
isn't persisted on HrrrProfile rows and would otherwise be lost).

AsosAdjustmentWorker pulls MrmsCache on every tick and passes the grid
through to AsosNudge.compute/4. Also skips the IemClient error branch
that can never happen and handles the ASOS-empty + MRMS-empty case
explicitly. MrmsCache wired into the supervision tree; MrmsFetchWorker
cron entry added to config.exs and dev.exs.

Four new AsosNudge cases cover MRMS-only re-scoring, threshold gating,
and the wet/dry score delta.

Beacons 500
-----------
Beacon.format_freq/1 and format_mw/1 crashed on whole-number floats
(e.g. 24192.0) because `frac == 0.0` could become false under float
rounding while `trim_trailing_zeros/1` stripped the decimal point,
leaving a 1-element list that couldn't be destructured as [_, frac].
Shared format_number/1 helper handles integer input directly and
pattern-matches both the "int-only" and "int + frac" shapes.

Added stream_data property tests covering the whole microwave range for
both integers and floats to catch this class of bug before prod.

UTC clock flash
---------------
The /weather and /map UTC clocks were empty until the JS hook mounted
post-WebSocket, producing a several-second blank spot on initial load
and a clobber risk on sidebar re-renders. Mount now computes a
server-rendered `initial_utc_clock` string and the template seeds the
element with that plus `phx-update="ignore"` so LiveView morphdom won't
overwrite what the hook writes.
2026-04-12 14:49:20 -05:00

172 lines
5.5 KiB
Elixir

defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
@moduledoc """
Nudge the propagation score grid with fresh ASOS observations between
hourly HRRR runs.
On each cron tick (every 10 minutes) this worker:
1. Finds the most recent `valid_time` that actually has HRRR profiles
persisted.
2. Pulls current ASOS observations from all state networks via
`Microwaveprop.Weather.IemClient.fetch_current_asos/1`, keeps only
those within 30 minutes of now.
3. Loads every `hrrr_profiles` row on the 0.125° propagation grid for
that valid_time into memory as plain maps.
4. Hands both lists to `Microwaveprop.Propagation.AsosNudge.compute/3`
— pure function, IDW bias field, upper-air fields preserved.
5. Upserts the nudged scores onto `(lat, lon, valid_time, band_mhz)`,
overwriting the HRRR-only values for grid cells within 250 km of a
reporting station. Cells outside that radius are left alone.
6. Warms the `ScoreCache` and broadcasts `propagation:updated` so every
connected `/map` client refreshes.
This worker owns the I/O. `AsosNudge` stays pure so its tests don't need
Repo or network access.
"""
use Oban.Worker, queue: :propagation, max_attempts: 3
import Ecto.Query, only: [from: 2]
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.AsosNudge
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.MrmsCache
require Logger
@state_networks ~w(
AL_ASOS AK_ASOS AZ_ASOS AR_ASOS CA_ASOS CO_ASOS CT_ASOS DE_ASOS
FL_ASOS GA_ASOS HI_ASOS ID_ASOS IL_ASOS IN_ASOS IA_ASOS KS_ASOS
KY_ASOS LA_ASOS ME_ASOS MD_ASOS MA_ASOS MI_ASOS MN_ASOS MS_ASOS
MO_ASOS MT_ASOS NE_ASOS NV_ASOS NH_ASOS NJ_ASOS NM_ASOS NY_ASOS
NC_ASOS ND_ASOS OH_ASOS OK_ASOS OR_ASOS PA_ASOS RI_ASOS SC_ASOS
SD_ASOS TN_ASOS TX_ASOS UT_ASOS VT_ASOS VA_ASOS WA_ASOS WV_ASOS
WI_ASOS WY_ASOS DC_ASOS
)
@max_obs_age_seconds 1800
@impl Oban.Worker
def perform(%Oban.Job{}) do
with {:ok, valid_time} <- latest_hrrr_valid_time(),
[_ | _] = profiles <- load_hrrr_profiles(valid_time) do
fresh = fetch_fresh_observations()
rain_grid = mrms_rain_grid()
if fresh == [] and map_size(rain_grid) == 0 do
Logger.info("AsosAdjustment: no fresh ASOS obs and no MRMS rain cached, skipping")
:ok
else
apply_nudge(fresh, profiles, valid_time, rain_grid)
end
else
:no_hrrr ->
Logger.info("AsosAdjustment: no HRRR profiles yet, skipping")
:ok
[] ->
Logger.info("AsosAdjustment: HRRR valid_time has no grid profiles, skipping")
:ok
end
end
defp fetch_fresh_observations do
# IemClient.fetch_current_asos/1 swallows its own per-network HTTP
# errors and always returns `{:ok, [...]}` (possibly empty). We just
# filter the list down to observations inside the freshness window.
{:ok, observations} = IemClient.fetch_current_asos(@state_networks)
filter_fresh(observations)
end
defp mrms_rain_grid do
case MrmsCache.fetch() do
{:ok, _valid_time, grid} -> grid
:miss -> %{}
end
end
defp apply_nudge(observations, profiles, valid_time, rain_grid) do
scores = AsosNudge.compute(observations, valid_time, profiles, rain_grid)
if scores == [] do
Logger.info(
"AsosAdjustment: #{length(observations)} fresh obs + #{map_size(rain_grid)} MRMS cells but no grid cells eligible"
)
:ok
else
case Propagation.upsert_scores(scores, prune: false) do
{:ok, count} ->
Logger.info(
"AsosAdjustment: nudged #{count} scores from #{length(observations)} obs + #{map_size(rain_grid)} MRMS cells at #{valid_time}"
)
warm_and_broadcast(valid_time)
:ok
error ->
Logger.error("AsosAdjustment: upsert failed — #{inspect(error)}")
error
end
end
end
defp warm_and_broadcast(valid_time) do
Enum.each(BandConfig.all_bands(), fn band ->
Propagation.warm_cache_and_broadcast(band.freq_mhz, valid_time)
end)
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:updated",
{:propagation_updated, valid_time}
)
end
defp latest_hrrr_valid_time do
case Repo.one(
from(h in HrrrProfile,
where: h.is_grid_point == true,
select: max(h.valid_time)
)
) do
nil -> :no_hrrr
vt -> {:ok, vt}
end
end
defp filter_fresh(observations) do
cutoff = DateTime.add(DateTime.utc_now(), -@max_obs_age_seconds, :second)
Enum.filter(observations, fn obs ->
obs.utc_valid && DateTime.after?(obs.utc_valid, cutoff)
end)
end
defp load_hrrr_profiles(valid_time) do
Repo.all(
from(h in HrrrProfile,
where: h.valid_time == ^valid_time and h.is_grid_point == true,
select: %{
lat: h.lat,
lon: h.lon,
valid_time: h.valid_time,
surface_temp_c: h.surface_temp_c,
surface_dewpoint_c: h.surface_dewpoint_c,
surface_pressure_mb: h.surface_pressure_mb,
surface_refractivity: h.surface_refractivity,
min_refractivity_gradient: h.min_refractivity_gradient,
hpbl_m: h.hpbl_m,
pwat_mm: h.pwat_mm,
ducting_detected: h.ducting_detected,
duct_characteristics: h.duct_characteristics,
profile: h.profile
}
)
)
end
end