prop/lib/microwaveprop/propagation.ex
Graham McIntire 07ffcf52d7
Flip map read path to ScoresFile, stop writing grid HRRR profiles
Read-side cutover for the binary scores store and a companion
cleanup that removes the biggest remaining DB write from the hot
path.

Propagation.scores_at/3, available_valid_times/1, latest_valid_time/0,
latest_valid_time/1, earliest_valid_time/1, point_detail/4, and
point_forecast/3 all now prefer ScoresFile and fall back to the
propagation_scores table when a file is missing. The map render
path reads from /data/scores first; Postgres stays as a safety
net while dual-write is on. point_detail still pulls factors from
Postgres (analysis-hour rows only) and coalesces nil to an empty
map so the JS popup iterates cleanly.

replace_scores/2 is now gated by a postgres_writes_enabled? flag
(runtime env MICROWAVEPROP_SCORES_POSTGRES=false, or the
:propagation_scores_postgres app env key) so the binary-only path
can be benchmarked locally without the DB insert. Default stays
true.

PropagationGridWorker no longer calls store_hrrr_profiles —
persisting 92k grid rows × 19 forecast hours of JSONB profiles
was ~12 min of wall time per chain for a table only
AsosAdjustmentWorker read from. Per-contact HRRR enrichment
through HrrrFetchWorker still writes its own (is_grid_point:
false) rows. AsosAdjustmentWorker is disabled in all three cron
configs since its data source is gone.

DataCase resets the scores tree between tests so per-test
ScoresFile writes don't leak across cases, and ScoresFileTest
switches to async: false because it mutates the global
:propagation_scores_dir env.
2026-04-14 14:50:43 -05:00

661 lines
22 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Propagation do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo
alias Microwaveprop.Weather.SoundingParams
require Logger
@ml_key :propagation_ml
@ml_module Microwaveprop.Propagation.Model
@doc """
Loads the ML model from disk, compiles the predict function, and caches both
in persistent_term. No-op if the model file doesn't exist or ML deps unavailable.
"""
@spec load_ml_model() :: :ok
def load_ml_model do
if Code.ensure_loaded?(@ml_module) do
case @ml_module.load() do
{:ok, params} ->
predict_fn = @ml_module.compile_predict()
:persistent_term.put(@ml_key, {predict_fn, params})
Logger.info("PropagationML: model loaded and compiled")
:ok
:error ->
Logger.info("PropagationML: no model file found, using algorithm scorer only")
:ok
end
else
Logger.info("PropagationML: ML dependencies not available")
:ok
end
end
@doc "Returns cached {predict_fn, params} tuple, or nil if not loaded."
@spec ml_model() :: {function(), term()} | nil
def ml_model do
:persistent_term.get(@ml_key, nil)
end
@doc """
Score a single grid point across all bands using HRRR profile data.
Uses ML model if loaded, falls back to algorithm scorer.
Returns a list of %{band_mhz, score, factors} maps.
"""
@spec score_grid_point(map(), DateTime.t(), float(), float()) ::
[%{band_mhz: non_neg_integer(), score: non_neg_integer(), factors: map()}]
def score_grid_point(hrrr_profile, valid_time, latitude, longitude) do
derived = derive_from_hrrr(hrrr_profile)
temp_c = hrrr_profile.surface_temp_c
dewpoint_c = hrrr_profile.surface_dewpoint_c
# Skip points with missing or physically impossible surface data
if is_nil(temp_c) or is_nil(dewpoint_c) or temp_c < -80 or temp_c > 60 or
dewpoint_c < -80 or dewpoint_c > 50 do
[]
else
score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
end
end
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude) do
# Algorithm is the primary scorer — always used for the map score.
# ML score stored in factors as :ml_score for comparison/analysis.
score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, latitude, longitude)
end
defp score_with_algorithm(hrrr_profile, valid_time, temp_c, dewpoint_c, derived, _latitude, longitude) do
temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
conditions = %{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: temp_f,
dewpoint_f: dewpoint_f,
wind_speed_kts: Scorer.wind_speed_kts(hrrr_profile[:wind_u], hrrr_profile[:wind_v]),
sky_cover_pct: hrrr_profile[:cloud_cover_pct],
utc_hour: valid_time.hour,
utc_minute: valid_time.minute,
month: valid_time.month,
longitude: longitude,
pressure_mb: hrrr_profile.surface_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: merged_rain_rate(hrrr_profile),
min_refractivity_gradient: hrrr_profile[:native_min_gradient] || derived[:min_refractivity_gradient],
bl_depth_m: hrrr_profile[:hpbl_m],
pwat_mm: hrrr_profile[:pwat_mm],
best_duct_band_ghz: hrrr_profile[:best_duct_freq_ghz] || hrrr_profile[:best_duct_band_ghz]
}
duct_info =
if hrrr_profile[:duct_count] && hrrr_profile[:duct_count] > 0 do
%{
duct_count: hrrr_profile[:duct_count],
best_duct_freq_ghz: hrrr_profile[:best_duct_freq_ghz],
max_duct_thickness_m: hrrr_profile[:max_duct_thickness_m],
ducts: hrrr_profile[:ducts] || []
}
end
link_degradation = hrrr_profile[:commercial_link_degradation]
Enum.map(BandConfig.all_bands(), fn band_config ->
result = Scorer.composite_score(conditions, band_config)
boosted_score =
if link_degradation do
Scorer.commercial_link_boost(result.score, link_degradation)
else
result.score
end
result =
result
|> Map.put(:score, boosted_score)
|> Map.put(:band_mhz, band_config.freq_mhz)
result =
if link_degradation do
put_in(result, [:factors, :commercial_link_degradation], link_degradation)
else
result
end
if duct_info, do: put_in(result, [:factors, :duct_info], duct_info), else: result
end)
end
# Pick the heavier of HRRR's hourly accumulation-derived rate and NEXRAD's
# reflectivity-derived rate. NEXRAD catches fast-moving convective cells that
# fall between HRRR hourly analyses; HRRR catches broad stratiform rain that
# NEXRAD reports as low dBZ. Taking max lets either source trigger the rain
# penalty without double-counting.
defp merged_rain_rate(hrrr_profile) do
hrrr_rate = Scorer.precip_to_rate_mmhr(hrrr_profile[:precip_mm])
nexrad_rate = Scorer.dbz_to_rain_rate_mmhr(hrrr_profile[:nexrad_max_reflectivity_dbz])
max(hrrr_rate, nexrad_rate)
end
@doc """
Replace every propagation score for `valid_time` with `scores`.
Used by `PropagationGridWorker` on the hot path, which rewrites the
entire (valid_time, all-bands) slice every forecast hour. Skips the
`ON CONFLICT DO UPDATE` machinery of `upsert_scores/2` — conflict
detection is wasted work when we know the full slice is being
rewritten. In prod this cuts the scoring+upsert phase from ~4m40s
per forecast hour to well under half that.
**Not** suitable for `AsosAdjustmentWorker`, which rewrites only a
subset of cells that happen to be near an ASOS station. That path
must stay on `upsert_scores/2`.
Runs inside a single transaction so readers see all-or-nothing. An
empty `scores` list still deletes the pre-existing slice, so a
partial-failure run doesn't leave stale data visible.
"""
@spec replace_scores(Enumerable.t(), DateTime.t()) :: {:ok, non_neg_integer()} | {:error, term()}
def replace_scores(scores, %DateTime{} = valid_time) do
# Materialize once — the DB insert and the per-band ScoresFile
# writer both traverse the stream. 475k maps × ~150 B each is
# ~70 MB, well within the pod memory budget.
scores_list = Enum.to_list(scores)
result =
if postgres_writes_enabled?() do
replace_postgres_scores(scores_list, valid_time)
else
{:ok, length(scores_list)}
end
case result do
{:ok, _count} -> write_score_files(scores_list, valid_time)
_error -> :ok
end
result
end
# Toggle for benchmarking the binary-file-only path. Flip to false
# via `config :microwaveprop, :propagation_scores_postgres: false`
# (or set the env var `MICROWAVEPROP_SCORES_POSTGRES=false`) in dev
# to skip the DB insert entirely.
defp postgres_writes_enabled? do
case System.get_env("MICROWAVEPROP_SCORES_POSTGRES") do
"false" -> false
"0" -> false
_ -> Application.get_env(:microwaveprop, :propagation_scores_postgres, true)
end
end
defp replace_postgres_scores(scores_list, valid_time) do
now = DateTime.truncate(DateTime.utc_now(), :second)
Repo.transaction(
fn ->
Repo.delete_all(from(gs in GridScore, where: gs.valid_time == ^valid_time), timeout: 300_000)
scores_list
|> Stream.map(fn s ->
%{
id: Ecto.UUID.generate(),
lat: s.lat,
lon: s.lon,
valid_time: s.valid_time,
band_mhz: s.band_mhz,
score: s.score,
factors: s.factors,
inserted_at: now,
updated_at: now
}
end)
|> Stream.chunk_every(1000)
|> Enum.reduce(0, fn chunk, acc ->
{count, _} = Repo.insert_all(GridScore, chunk)
acc + count
end)
end,
timeout: 600_000
)
end
# Dual-write the grid to disk-backed ScoresFile binaries so the map
# render path can eventually read directly from /data/scores without
# touching Postgres. Best-effort — any file error is logged and the
# DB write still counts as the source of truth until the cutover.
defp write_score_files(scores, valid_time) do
scores
|> Enum.group_by(& &1.band_mhz)
|> Enum.each(fn {band_mhz, band_scores} ->
try do
ScoresFile.write!(band_mhz, valid_time, band_scores)
rescue
e ->
Logger.warning("Propagation: ScoresFile write failed for band=#{band_mhz} vt=#{valid_time}: #{inspect(e)}")
end
end)
end
@doc """
Upsert propagation scores in batches within a transaction so readers see all-or-nothing.
Options:
* `:prune` - whether to prune old scores after upsert (default true)
"""
@spec upsert_scores(Enumerable.t(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
def upsert_scores(scores, opts \\ []) do
now = DateTime.truncate(DateTime.utc_now(), :second)
result =
Repo.transaction(
fn ->
scores
|> Stream.map(fn s ->
%{
id: Ecto.UUID.generate(),
lat: s.lat,
lon: s.lon,
valid_time: s.valid_time,
band_mhz: s.band_mhz,
score: s.score,
factors: s.factors,
inserted_at: now,
updated_at: now
}
end)
|> Stream.chunk_every(500)
|> Enum.reduce(0, fn chunk, acc ->
{count, _} =
Repo.insert_all(GridScore, chunk,
on_conflict:
from(g in GridScore,
update: [
set: [
score: fragment("EXCLUDED.score"),
factors: fragment("EXCLUDED.factors"),
updated_at: fragment("EXCLUDED.updated_at")
]
],
where: g.score != fragment("EXCLUDED.score")
),
conflict_target: [:lat, :lon, :valid_time, :band_mhz]
)
acc + count
end)
end,
timeout: 600_000
)
if Keyword.get(opts, :prune, true) do
case result do
{:ok, _count} -> prune_old_scores()
_ -> :ok
end
end
result
end
@doc """
Remove scores with valid_times older than 2 hours. Called on a cron by
`Microwaveprop.Workers.PropagationPruneWorker` and also at the start of
each `PropagationGridWorker.perform/1` as a safety net. The 5-minute
timeout gives enough headroom for a catch-up run (potentially millions of
rows across four indexes) after a stretch of failed compute jobs.
"""
@spec prune_old_scores() :: :ok
def prune_old_scores do
cutoff = DateTime.add(DateTime.utc_now(), -2, :hour)
{deleted, _} =
Repo.delete_all(from(gs in GridScore, where: gs.valid_time < ^cutoff), timeout: 300_000)
if deleted > 0 do
Logger.info("PropagationScores: pruned #{deleted} old scores (before #{cutoff})")
end
file_deleted = ScoresFile.prune_older_than(cutoff)
if file_deleted > 0 do
Logger.info("PropagationScores: pruned #{file_deleted} old score files (before #{cutoff})")
end
:ok
end
@doc """
Returns distinct valid_times for a band, ordered ascending.
Filters out times more than 1 hour in the past, but always includes
the most recent valid_time so there's always data to display.
"""
@spec available_valid_times(non_neg_integer()) :: [DateTime.t()]
def available_valid_times(band_mhz) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
case ScoreCache.valid_times(band_mhz) do
[] -> available_valid_times_from_store(band_mhz, cutoff)
cached -> filter_fresh(cached, cutoff)
end
end
defp filter_fresh(times, cutoff) do
Enum.filter(times, fn t -> DateTime.compare(t, cutoff) != :lt end)
end
defp available_valid_times_from_store(band_mhz, cutoff) do
case ScoresFile.list_valid_times(band_mhz) do
[] -> available_valid_times_from_db(band_mhz, cutoff)
times -> filter_or_latest(times, cutoff)
end
end
defp filter_or_latest(times, cutoff) do
fresh = Enum.filter(times, fn t -> DateTime.compare(t, cutoff) != :lt end)
if fresh == [] do
[Enum.max(times, DateTime)]
else
fresh
end
end
defp available_valid_times_from_db(band_mhz, cutoff) do
times =
Repo.all(
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time >= ^cutoff,
select: gs.valid_time,
distinct: gs.valid_time,
order_by: [asc: gs.valid_time]
)
)
if times == [] do
# No future times — return the single most recent as fallback
case latest_valid_time(band_mhz) do
nil -> []
t -> [t]
end
else
times
end
end
@doc """
Get scores for a band at a specific valid_time, optionally within a bounding box.
If valid_time is nil, uses the earliest available (current analysis hour).
Excludes factors for performance.
"""
@spec scores_at(non_neg_integer(), DateTime.t() | nil, map() | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
def scores_at(band_mhz, valid_time, bounds \\ nil) do
time = valid_time || earliest_valid_time(band_mhz)
case time do
nil ->
[]
_ ->
scores_at_fetch(band_mhz, time, bounds)
end
end
defp scores_at_fetch(band_mhz, time, bounds) do
case ScoreCache.fetch_bounds(band_mhz, time, bounds) do
{:ok, scores} ->
Enum.map(scores, &Map.put(&1, :valid_time, time))
:miss ->
full = load_scores_from_store(band_mhz, time)
ScoreCache.put(band_mhz, time, full)
full
|> filter_bounds(bounds)
|> Enum.map(&Map.put(&1, :valid_time, time))
end
end
defp load_scores_from_store(band_mhz, time) do
case ScoresFile.read_bounds(band_mhz, time) do
[] -> load_scores_from_db(band_mhz, time)
scores -> scores
end
end
defp load_scores_from_db(band_mhz, time) do
Repo.all(
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time == ^time,
select: %{lat: gs.lat, lon: gs.lon, score: gs.score}
)
)
end
@doc """
Load the full CONUS score set for `{band_mhz, valid_time}` from the DB and
broadcast it to every `ScoreCache` in the cluster. Intended to be called from
`PropagationGridWorker` after each upsert so all pods have a warm cache by
the time clients begin requesting the new hour.
"""
@spec warm_cache_and_broadcast(non_neg_integer(), DateTime.t()) :: :ok
def warm_cache_and_broadcast(band_mhz, valid_time) do
scores = load_scores_from_db(band_mhz, valid_time)
ScoreCache.broadcast_put(band_mhz, valid_time, scores)
:ok
end
defp filter_bounds(scores, nil), do: scores
defp filter_bounds(scores, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
Enum.filter(scores, fn %{lat: lat, lon: lon} ->
lat >= s and lat <= n and lon >= w and lon <= e
end)
end
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)."
@spec latest_scores(non_neg_integer(), map() | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
def latest_scores(band_mhz, bounds \\ nil) do
scores_at(band_mhz, nil, bounds)
end
defp earliest_valid_time(band_mhz) do
case ScoresFile.list_valid_times(band_mhz) do
[earliest | _] ->
earliest
[] ->
Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: min(gs.valid_time)))
end
end
@doc "Get scores across all forecast hours for a single grid point (for sparkline)."
@spec point_forecast(non_neg_integer(), float(), float()) ::
[%{valid_time: DateTime.t(), score: non_neg_integer()}]
def point_forecast(band_mhz, lat, lon) do
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
now = DateTime.utc_now()
case ScoreCache.valid_times(band_mhz) do
[] -> point_forecast_from_store(band_mhz, snapped_lat, snapped_lon, now)
cached -> point_forecast_from_cache(band_mhz, snapped_lat, snapped_lon, now, cached)
end
end
defp point_forecast_from_store(band_mhz, lat, lon, now) do
case ScoresFile.list_valid_times(band_mhz) do
[] ->
point_forecast_from_db(band_mhz, lat, lon, now)
times ->
times
|> Enum.filter(fn t -> DateTime.compare(t, now) != :lt end)
|> Enum.map(&point_forecast_entry(&1, band_mhz, lat, lon))
|> Enum.reject(&is_nil/1)
end
end
defp point_forecast_entry(valid_time, band_mhz, lat, lon) do
case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do
nil -> nil
score -> %{valid_time: valid_time, score: score}
end
end
defp point_forecast_from_cache(band_mhz, lat, lon, now, cached_times) do
cached_times
|> Enum.filter(fn t -> DateTime.compare(t, now) != :lt end)
|> Enum.map(fn t ->
case ScoreCache.fetch_point(band_mhz, t, lat, lon) do
{:ok, score} -> %{valid_time: t, score: score}
:miss -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp point_forecast_from_db(band_mhz, lat, lon, now) do
Repo.all(
from(gs in GridScore,
where:
gs.band_mhz == ^band_mhz and
gs.lat == ^lat and
gs.lon == ^lon and
gs.valid_time >= ^now,
select: %{valid_time: gs.valid_time, score: gs.score},
order_by: [asc: gs.valid_time]
)
)
end
defp snap_to_grid(lat, lon) do
step = Grid.step()
{Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)}
end
@doc "Get the full score and factors for a specific grid point, snapped to nearest grid."
@spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) ::
%{
lat: float(),
lon: float(),
score: non_neg_integer(),
factors: map(),
valid_time: DateTime.t()
}
| nil
def point_detail(band_mhz, lat, lon, valid_time \\ nil) do
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
time = valid_time || latest_valid_time(band_mhz)
case time do
nil -> nil
_ -> build_point_detail(band_mhz, time, snapped_lat, snapped_lon)
end
end
defp build_point_detail(band_mhz, time, lat, lon) do
case ScoresFile.read_point(band_mhz, time, lat, lon) do
nil ->
point_detail_from_db(band_mhz, time, lat, lon)
score ->
%{
lat: lat,
lon: lon,
score: score,
factors: fetch_factors(band_mhz, time, lat, lon) || %{},
valid_time: time
}
end
end
defp point_detail_from_db(band_mhz, time, lat, lon) do
from(gs in GridScore,
where:
gs.band_mhz == ^band_mhz and
gs.valid_time == ^time and
gs.lat == ^lat and
gs.lon == ^lon,
select: %{
lat: gs.lat,
lon: gs.lon,
score: gs.score,
factors: gs.factors,
valid_time: gs.valid_time
}
)
|> Repo.one()
|> coalesce_factors()
end
# Factors live only in Postgres (only for f00 analysis rows, since
# forecast hours skip the JSONB write entirely). Returns nil if
# the row isn't there — the caller falls back to an empty map so
# the JS popup iterates cleanly.
defp fetch_factors(band_mhz, time, lat, lon) do
Repo.one(
from(gs in GridScore,
where:
gs.band_mhz == ^band_mhz and
gs.valid_time == ^time and
gs.lat == ^lat and
gs.lon == ^lon,
select: gs.factors
)
)
end
# Forecast-hour rows skip the factors JSONB to save write time, so
# a point-detail lookup on a non-f00 valid_time returns a row whose
# `factors` is `nil`. Normalize to `%{}` so the JS popup can iterate
# without blowing up on a null access.
defp coalesce_factors(nil), do: nil
defp coalesce_factors(%{factors: nil} = detail), do: %{detail | factors: %{}}
defp coalesce_factors(detail), do: detail
@doc "Get the latest valid_time across all scores."
@spec latest_valid_time() :: DateTime.t() | nil
def latest_valid_time do
case ScoresFile.latest_valid_time() do
nil -> Repo.one(from(gs in GridScore, select: max(gs.valid_time)))
dt -> dt
end
end
@doc "Get the latest valid_time for a specific band."
@spec latest_valid_time(non_neg_integer()) :: DateTime.t() | nil
def latest_valid_time(band_mhz) do
case ScoresFile.list_valid_times(band_mhz) do
[] -> Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: max(gs.valid_time)))
times -> Enum.max(times, DateTime)
end
end
# Prefer the persisted scalar — `hrrr_profiles` already stored this at
# ingestion time and AsosAdjustmentWorker loads 92k rows per tick without
# the JSONB `profile` column to avoid a Jason.decode! storm on the DB pool.
defp derive_from_hrrr(%{min_refractivity_gradient: grad}) when is_number(grad) do
%{min_refractivity_gradient: grad * 1.0}
end
defp derive_from_hrrr(%{profile: profile}) when is_list(profile) and length(profile) >= 3 do
case SoundingParams.derive(profile) do
nil -> %{}
derived -> %{min_refractivity_gradient: derived.min_refractivity_gradient}
end
end
defp derive_from_hrrr(_), do: %{}
end