P0 (security-critical): - Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers - Cap CSV/ADIF imports at 2,000 rows server-side in both parsers - Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false) - Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT P1 (high-priority): - Add Mox.verify_on_exit!() to valkey_test.exs - Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs - Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences) - Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error) - Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis - Split ContactLive.Show render into 12 function components - Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs - Batch CSV import enrichment jobs via new enqueue_for_contacts/1 P2 (medium-priority): - Set secure:true on session and remember-me cookies in production - Change SMTP TLS from verify_none to verify_peer with public_key cacerts - Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset - Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map - Add content-security-policy-report-only header - Add comment noting String.to_atom is compile-time safe in hrdps_client.ex - Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4 - Consolidate score-tier/color/verdict formatting into Microwaveprop.Format - Update CLAUDE.md testing section to match actual raw-string-matching practice - Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip - Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades P3 (low-priority): - Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters - Add host/community validation to snmp_client.ex - Add raw/1 safety comment in algo_live.ex - Add hex-audit and cargo-audit Makefile targets - Add privacy_live smoke test - Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive - Add ContactCommonVolumeRadar changeset validation tests (5 tests)
306 lines
9.9 KiB
Elixir
306 lines
9.9 KiB
Elixir
defmodule Microwaveprop.Weather.Hrrr do
|
||
@moduledoc false
|
||
|
||
import Ecto.Query
|
||
|
||
alias Ecto.UUID
|
||
alias Microwaveprop.Propagation.Grid
|
||
alias Microwaveprop.Radio
|
||
alias Microwaveprop.Radio.Contact
|
||
alias Microwaveprop.Repo
|
||
alias Microwaveprop.Weather.HrrrNativeProfile
|
||
alias Microwaveprop.Weather.HrrrProfile
|
||
alias Microwaveprop.Weather.Narr
|
||
alias Microwaveprop.Weather.NarrProfile
|
||
alias Microwaveprop.Weather.ProfileLookup
|
||
alias Microwaveprop.Weather.SoundingParams
|
||
|
||
# ── Delegates to ProfileLookup ──
|
||
|
||
defdelegate find_nearest_hrrr(lat, lon, timestamp), to: ProfileLookup
|
||
defdelegate hrrr_profiles_for_path(contact), to: ProfileLookup
|
||
defdelegate hrrr_profiles_for_contacts(contacts), to: ProfileLookup
|
||
defdelegate find_nearest_native_profile(lat, lon, timestamp), to: ProfileLookup
|
||
defdelegate best_profile_for_contact(contact), to: ProfileLookup
|
||
defdelegate hrrr_for_contact(contact), to: ProfileLookup
|
||
defdelegate hrrr_data_fully_present?(contact), to: ProfileLookup
|
||
defdelegate has_hrrr_profile?(lat, lon, valid_time), to: ProfileLookup
|
||
defdelegate hrrr_points_present_batch(points), to: ProfileLookup
|
||
defdelegate round_to_hrrr_grid(lat, lon), to: ProfileLookup
|
||
defdelegate purge_grid_point_profiles(), to: ProfileLookup
|
||
|
||
# ── HRRR profile persistence ──
|
||
|
||
@spec upsert_hrrr_profile(map()) :: {:ok, HrrrProfile.t()} | {:error, Ecto.Changeset.t()}
|
||
def upsert_hrrr_profile(attrs) do
|
||
%HrrrProfile{}
|
||
|> HrrrProfile.changeset(attrs)
|
||
|> Repo.insert(
|
||
on_conflict: :nothing,
|
||
conflict_target: [:lat, :lon, :valid_time]
|
||
)
|
||
end
|
||
|
||
@spec upsert_hrrr_profiles_batch([map()], keyword()) :: {non_neg_integer(), nil}
|
||
def upsert_hrrr_profiles_batch(profiles, _opts \\ []) do
|
||
Microwaveprop.Instrument.span(
|
||
[:db, :upsert_hrrr_profiles],
|
||
%{count: length(profiles)},
|
||
fn -> do_upsert_hrrr_profiles_batch(profiles) end
|
||
)
|
||
end
|
||
|
||
defp do_upsert_hrrr_profiles_batch(profiles) do
|
||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||
|
||
profiles
|
||
|> Enum.chunk_every(500)
|
||
|> Enum.reduce({0, nil}, fn chunk, {total_count, _} ->
|
||
entries =
|
||
Enum.map(chunk, fn attrs ->
|
||
is_gp =
|
||
rem(round(attrs.lat * 1000), 125) == 0 and
|
||
rem(round(attrs.lon * 1000), 125) == 0
|
||
|
||
Map.merge(attrs, %{
|
||
id: UUID.generate(),
|
||
is_grid_point: is_gp,
|
||
inserted_at: now,
|
||
updated_at: now
|
||
})
|
||
end)
|
||
|
||
{count, rows} =
|
||
Repo.insert_all(HrrrProfile, entries,
|
||
on_conflict: :nothing,
|
||
conflict_target: [:lat, :lon, :valid_time]
|
||
)
|
||
|
||
{total_count + count, rows}
|
||
end)
|
||
end
|
||
|
||
# ── Native duct info ──
|
||
|
||
@doc """
|
||
Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
|
||
given cell. Convenience wrapper around `nearest_native_duct_info/3`
|
||
for callers that don't need Richardson.
|
||
"""
|
||
@spec nearest_native_duct_ghz(float(), float(), DateTime.t()) :: float() | nil
|
||
def nearest_native_duct_ghz(lat, lon, %DateTime{} = timestamp) do
|
||
case nearest_native_duct_info(lat, lon, timestamp) do
|
||
{:ok, %{best_duct_band_ghz: ghz}} -> ghz
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Returns `{:ok, %{best_duct_band_ghz: ghz, bulk_richardson: r}}` for
|
||
the nearest `hrrr_native_profiles` cell to (`lat`, `lon`) at
|
||
`timestamp` within ±0.07° / ±1h, or `{:error, :not_found}`.
|
||
|
||
Richardson gates the 1.15× refractivity boost in `Scorer` — a duct
|
||
band reading under turbulent conditions (high Richardson) is likely
|
||
to be mixed out before it supports the target path. The pair is
|
||
cheap to fetch together, so callers that score a cell prefer this
|
||
over the bare `nearest_native_duct_ghz/3`.
|
||
"""
|
||
@spec nearest_native_duct_info(float(), float(), DateTime.t()) ::
|
||
{:ok, %{best_duct_band_ghz: float() | nil, bulk_richardson: float() | nil}}
|
||
| {:error, :not_found}
|
||
def nearest_native_duct_info(lat, lon, %DateTime{} = timestamp) do
|
||
dlat = 0.07
|
||
dlon = 0.07
|
||
time_start = DateTime.add(timestamp, -3600, :second)
|
||
time_end = DateTime.add(timestamp, 3600, :second)
|
||
|
||
from(h in HrrrNativeProfile,
|
||
where:
|
||
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
|
||
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
|
||
h.valid_time >= ^time_start and h.valid_time <= ^time_end,
|
||
order_by:
|
||
fragment(
|
||
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
|
||
h.lat,
|
||
^lat,
|
||
h.lon,
|
||
^lon,
|
||
h.valid_time,
|
||
^timestamp
|
||
),
|
||
limit: 1,
|
||
select: %{best_duct_band_ghz: h.best_duct_band_ghz, bulk_richardson: h.bulk_richardson}
|
||
)
|
||
|> Repo.one()
|
||
|> case do
|
||
nil -> {:error, :not_found}
|
||
row -> {:ok, row}
|
||
end
|
||
end
|
||
|
||
# ── Reconciliation ──
|
||
|
||
@doc """
|
||
Reconcile `contacts.hrrr_status` for every `:queued` contact:
|
||
|
||
* flips to `:complete` when every path point has an hrrr_profiles
|
||
row at the rounded HRRR hour;
|
||
* flips to `:unavailable` when any path point falls outside the
|
||
HRRR CONUS grid (pos2 or the great-circle midpoint can drift
|
||
into Canada, the mid-Atlantic, or the Pacific even when pos1 is
|
||
stateside — the Rust hrrr-point-worker returns no profile for
|
||
those points and the contact would otherwise sit `:queued`
|
||
forever).
|
||
|
||
Returns `{:ok, n}` where `n` is the total number of contacts advanced
|
||
(completed + oconus).
|
||
"""
|
||
@spec reconcile_hrrr_statuses() :: {:ok, non_neg_integer()}
|
||
def reconcile_hrrr_statuses do
|
||
queued =
|
||
Contact
|
||
|> where([c], c.hrrr_status == :queued and not is_nil(c.pos1))
|
||
|> Repo.all()
|
||
|
||
{oconus, rest} = Enum.split_with(queued, &contact_has_oconus_path_point?/1)
|
||
to_complete = Enum.filter(rest, &hrrr_data_fully_present?/1)
|
||
|
||
_ =
|
||
if oconus != [] do
|
||
ids = Enum.map(oconus, & &1.id)
|
||
Radio.set_enrichment_status!(ids, :hrrr_status, :unavailable)
|
||
end
|
||
|
||
_ =
|
||
if to_complete != [] do
|
||
ids = Enum.map(to_complete, & &1.id)
|
||
Radio.set_enrichment_status!(ids, :hrrr_status, :complete)
|
||
end
|
||
|
||
{:ok, length(oconus) + length(to_complete)}
|
||
end
|
||
|
||
defp contact_has_oconus_path_point?(contact) do
|
||
contact
|
||
|> Radio.contact_path_points()
|
||
|> Enum.any?(fn {lat, lon} ->
|
||
not Grid.contains?(%{"lat" => lat, "lon" => lon})
|
||
end)
|
||
end
|
||
|
||
# ── Backfill scalars ──
|
||
|
||
@doc """
|
||
Derive `surface_refractivity` / `min_refractivity_gradient` /
|
||
`ducting_detected` for every `hrrr_profiles` row where those columns
|
||
are NULL but the pressure-level `profile` JSONB is populated.
|
||
Required one-off after the Rust hrrr_points worker shipped without
|
||
deriving these scalars — existing rows have the raw levels but the
|
||
DB scalars are NULL. Idempotent; rows with scalars already set are
|
||
skipped by the WHERE clause.
|
||
|
||
Batches by `limit` rows per pass to keep transactions short on the
|
||
Turing Pi 2 Postgres node. Returns the total number of rows advanced.
|
||
"""
|
||
@spec backfill_hrrr_scalars(keyword()) :: non_neg_integer()
|
||
def backfill_hrrr_scalars(opts \\ []) do
|
||
batch = Keyword.get(opts, :batch_size, 500)
|
||
max_batches = Keyword.get(opts, :max_batches, 1_000)
|
||
|
||
Enum.reduce_while(1..max_batches, 0, fn _, total ->
|
||
case backfill_hrrr_batch(batch) do
|
||
0 -> {:halt, total}
|
||
n -> {:cont, total + n}
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp backfill_hrrr_batch(limit) do
|
||
rows =
|
||
HrrrProfile
|
||
|> where([h], is_nil(h.surface_refractivity))
|
||
|> limit(^limit)
|
||
|> select([h], %{id: h.id, profile: h.profile})
|
||
|> Repo.all()
|
||
|
||
updates =
|
||
Enum.flat_map(rows, fn %{id: id, profile: profile} ->
|
||
case SoundingParams.derive(profile || []) do
|
||
%{} = derived ->
|
||
[
|
||
%{
|
||
id: id,
|
||
surface_refractivity: Map.get(derived, :surface_refractivity),
|
||
min_refractivity_gradient: Map.get(derived, :min_refractivity_gradient),
|
||
ducting_detected: Map.get(derived, :ducting_detected, false),
|
||
duct_characteristics: Map.get(derived, :duct_characteristics)
|
||
}
|
||
]
|
||
|
||
_ ->
|
||
[]
|
||
end
|
||
end)
|
||
|
||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||
|
||
_ =
|
||
if updates != [] do
|
||
{values_sql, params} =
|
||
updates
|
||
|> Enum.with_index()
|
||
|> Enum.reduce({"", []}, &build_values_row(&1, &2, now))
|
||
|
||
Repo.query!(
|
||
"UPDATE hrrr_profiles AS h " <>
|
||
"SET surface_refractivity = v.surface_refractivity, " <>
|
||
"min_refractivity_gradient = v.min_refractivity_gradient, " <>
|
||
"ducting_detected = v.ducting_detected, " <>
|
||
"duct_characteristics = v.duct_characteristics, " <>
|
||
"updated_at = v.updated_at " <>
|
||
"FROM (VALUES #{values_sql}) " <>
|
||
"AS v(id, surface_refractivity, min_refractivity_gradient, " <>
|
||
"ducting_detected, duct_characteristics, updated_at) " <>
|
||
"WHERE h.id = v.id",
|
||
params
|
||
)
|
||
end
|
||
|
||
length(updates)
|
||
end
|
||
|
||
defp build_values_row({u, _idx}, {sql, params}, now) do
|
||
base = Enum.count(params)
|
||
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
|
||
sep = if sql == "", do: "", else: ", "
|
||
|
||
params =
|
||
params ++
|
||
[
|
||
UUID.dump!(u.id),
|
||
u.surface_refractivity,
|
||
u.min_refractivity_gradient,
|
||
u.ducting_detected,
|
||
u.duct_characteristics || [],
|
||
now
|
||
]
|
||
|
||
{sql <> sep <> frag, params}
|
||
end
|
||
|
||
# ── Path profiles ──
|
||
|
||
@doc "Find all atmospheric profiles along a contact's path, from any source."
|
||
@spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()]
|
||
def profiles_along_path(contact) do
|
||
hrrr_path = hrrr_profiles_for_path(contact)
|
||
|
||
if hrrr_path == [] do
|
||
Narr.narr_profiles_for_path(contact)
|
||
else
|
||
hrrr_path
|
||
end
|
||
end
|
||
end
|