prop/lib/microwaveprop/weather/hrrr.ex
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
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)
2026-07-27 18:19:37 -05:00

306 lines
9.9 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.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