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.
661 lines
22 KiB
Elixir
661 lines
22 KiB
Elixir
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
|