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)
452 lines
16 KiB
Elixir
452 lines
16 KiB
Elixir
defmodule Microwaveprop.Weather.Surface do
|
||
@moduledoc false
|
||
|
||
import Ecto.Query
|
||
|
||
alias Ecto.UUID
|
||
alias Microwaveprop.Repo
|
||
alias Microwaveprop.Weather.IemClient
|
||
alias Microwaveprop.Weather.SolarIndex
|
||
alias Microwaveprop.Weather.Station
|
||
alias Microwaveprop.Weather.SurfaceObservation
|
||
|
||
require Logger
|
||
|
||
# Approximate km per degree latitude
|
||
@km_per_deg_lat 111.0
|
||
|
||
@spec find_or_create_station(map()) :: {:ok, Station.t()} | {:error, Ecto.Changeset.t()}
|
||
def find_or_create_station(attrs) do
|
||
code = attrs[:station_code] || attrs["station_code"]
|
||
type = attrs[:station_type] || attrs["station_type"]
|
||
|
||
if code && type do
|
||
case Repo.get_by(Station, station_code: code, station_type: type) do
|
||
nil ->
|
||
%Station{}
|
||
|> Station.changeset(attrs)
|
||
|> Repo.insert()
|
||
|
||
station ->
|
||
{:ok, station}
|
||
end
|
||
else
|
||
%Station{}
|
||
|> Station.changeset(attrs)
|
||
|> Repo.insert()
|
||
end
|
||
end
|
||
|
||
@spec upsert_surface_observation(Station.t(), map()) :: {:ok, SurfaceObservation.t()} | {:error, Ecto.Changeset.t()}
|
||
def upsert_surface_observation(%Station{} = station, attrs) do
|
||
attrs = Map.put(attrs, :station_id, station.id)
|
||
|
||
%SurfaceObservation{}
|
||
|> SurfaceObservation.changeset(attrs)
|
||
|> Repo.insert(
|
||
on_conflict:
|
||
from(s in SurfaceObservation,
|
||
update: [
|
||
set: [
|
||
temp_f: fragment("EXCLUDED.temp_f"),
|
||
dewpoint_f: fragment("EXCLUDED.dewpoint_f"),
|
||
relative_humidity: fragment("EXCLUDED.relative_humidity"),
|
||
wind_speed_kts: fragment("EXCLUDED.wind_speed_kts"),
|
||
sea_level_pressure_mb: fragment("EXCLUDED.sea_level_pressure_mb"),
|
||
sky_condition: fragment("EXCLUDED.sky_condition"),
|
||
precip_1h_in: fragment("EXCLUDED.precip_1h_in"),
|
||
wx_codes: fragment("EXCLUDED.wx_codes"),
|
||
updated_at: fragment("EXCLUDED.updated_at")
|
||
]
|
||
],
|
||
where:
|
||
s.temp_f != fragment("EXCLUDED.temp_f") or
|
||
s.dewpoint_f != fragment("EXCLUDED.dewpoint_f") or
|
||
s.relative_humidity != fragment("EXCLUDED.relative_humidity") or
|
||
s.wind_speed_kts != fragment("EXCLUDED.wind_speed_kts") or
|
||
s.sea_level_pressure_mb != fragment("EXCLUDED.sea_level_pressure_mb")
|
||
),
|
||
conflict_target: [:station_id, :observed_at],
|
||
returning: true,
|
||
stale_error_field: :id
|
||
)
|
||
end
|
||
|
||
@doc """
|
||
Bulk-upsert surface observations for a single station via one
|
||
`Repo.insert_all` round-trip. ASOS fetches return 24–288 rows per
|
||
station per call — collapsing them into a single statement avoids the
|
||
per-row UPDATE-conflict round-trip of `upsert_surface_observation/2`,
|
||
which is expensive against the Turing Pi 2 Postgres node.
|
||
|
||
Rows missing `observed_at` are dropped (they can't satisfy the
|
||
`(station_id, observed_at)` unique index). Returns the
|
||
`{count, nil}` tuple from `Repo.insert_all/3`; `count` reflects
|
||
affected rows (new + updated-when-changed). Returns `{0, nil}` for
|
||
an empty input without touching the DB.
|
||
"""
|
||
@spec upsert_surface_observations(Station.t(), [map()]) :: {non_neg_integer(), nil}
|
||
def upsert_surface_observations(%Station{} = station, rows) when is_list(rows) do
|
||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||
|
||
entries =
|
||
rows
|
||
|> Enum.filter(&row_has_observed_at?/1)
|
||
|> Enum.map(&surface_observation_entry(&1, station.id, now))
|
||
|> dedupe_last_by_conflict_target()
|
||
|
||
case entries do
|
||
[] ->
|
||
{0, nil}
|
||
|
||
_ ->
|
||
Repo.insert_all(SurfaceObservation, entries,
|
||
on_conflict:
|
||
from(s in SurfaceObservation,
|
||
update: [
|
||
set: [
|
||
temp_f: fragment("EXCLUDED.temp_f"),
|
||
dewpoint_f: fragment("EXCLUDED.dewpoint_f"),
|
||
relative_humidity: fragment("EXCLUDED.relative_humidity"),
|
||
wind_speed_kts: fragment("EXCLUDED.wind_speed_kts"),
|
||
wind_direction_deg: fragment("EXCLUDED.wind_direction_deg"),
|
||
sea_level_pressure_mb: fragment("EXCLUDED.sea_level_pressure_mb"),
|
||
altimeter_setting: fragment("EXCLUDED.altimeter_setting"),
|
||
sky_condition: fragment("EXCLUDED.sky_condition"),
|
||
precip_1h_in: fragment("EXCLUDED.precip_1h_in"),
|
||
wx_codes: fragment("EXCLUDED.wx_codes"),
|
||
updated_at: fragment("EXCLUDED.updated_at")
|
||
]
|
||
],
|
||
where:
|
||
s.temp_f != fragment("EXCLUDED.temp_f") or
|
||
s.dewpoint_f != fragment("EXCLUDED.dewpoint_f") or
|
||
s.relative_humidity != fragment("EXCLUDED.relative_humidity") or
|
||
s.wind_speed_kts != fragment("EXCLUDED.wind_speed_kts") or
|
||
s.sea_level_pressure_mb != fragment("EXCLUDED.sea_level_pressure_mb")
|
||
),
|
||
conflict_target: [:station_id, :observed_at]
|
||
)
|
||
end
|
||
end
|
||
|
||
@spec has_surface_observations?(UUID.t(), DateTime.t(), DateTime.t()) :: boolean()
|
||
def has_surface_observations?(station_id, start_dt, end_dt) do
|
||
SurfaceObservation
|
||
|> where([o], o.station_id == ^station_id)
|
||
|> where([o], o.observed_at >= ^start_dt and o.observed_at <= ^end_dt)
|
||
|> Repo.exists?()
|
||
end
|
||
|
||
@doc """
|
||
True if the station already has at least one surface observation
|
||
anywhere within the given UTC date. Used by the `asos_day` worker
|
||
to short-circuit the IEM fetch when the day has already been
|
||
ingested by a prior run.
|
||
"""
|
||
@spec station_day_covered?(UUID.t(), Date.t()) :: boolean()
|
||
def station_day_covered?(station_id, date) do
|
||
{:ok, start_dt} = DateTime.new(date, ~T[00:00:00], "Etc/UTC")
|
||
{:ok, end_dt} = DateTime.new(date, ~T[23:59:59], "Etc/UTC")
|
||
has_surface_observations?(station_id, start_dt, end_dt)
|
||
end
|
||
|
||
@doc """
|
||
Returns the MapSet of `{station_id, date}` tuples already covered
|
||
(at least one obs in that UTC day). Used by the enqueuer to avoid
|
||
emitting jobs for combinations we've already fetched.
|
||
"""
|
||
@spec station_day_pairs_covered([{UUID.t(), Date.t()}]) ::
|
||
MapSet.t({UUID.t(), Date.t()})
|
||
def station_day_pairs_covered([]), do: MapSet.new()
|
||
|
||
def station_day_pairs_covered(pairs) do
|
||
station_ids = pairs |> Enum.map(&elem(&1, 0)) |> Enum.uniq()
|
||
dates = pairs |> Enum.map(&elem(&1, 1)) |> Enum.uniq()
|
||
{:ok, start_dt} = DateTime.new(Enum.min(dates, Date), ~T[00:00:00], "Etc/UTC")
|
||
{:ok, end_dt} = DateTime.new(Enum.max(dates, Date), ~T[23:59:59], "Etc/UTC")
|
||
|
||
rows =
|
||
SurfaceObservation
|
||
|> where([o], o.station_id in ^station_ids)
|
||
|> where([o], o.observed_at >= ^start_dt and o.observed_at <= ^end_dt)
|
||
|> select([o], {o.station_id, fragment("(?::date)", o.observed_at)})
|
||
|> distinct(true)
|
||
|> Repo.all()
|
||
|
||
MapSet.new(rows)
|
||
end
|
||
|
||
@doc "Returns a MapSet of station_ids that have surface observations in the time window."
|
||
@spec station_ids_with_surface_observations([UUID.t()], DateTime.t(), DateTime.t()) :: MapSet.t(UUID.t())
|
||
def station_ids_with_surface_observations(station_ids, start_dt, end_dt) do
|
||
SurfaceObservation
|
||
|> where([o], o.station_id in ^station_ids)
|
||
|> where([o], o.observed_at >= ^start_dt and o.observed_at <= ^end_dt)
|
||
|> select([o], o.station_id)
|
||
|> distinct(true)
|
||
|> Repo.all()
|
||
|> MapSet.new()
|
||
end
|
||
|
||
@spec nearby_stations(float(), float(), String.t(), number()) :: [Station.t()]
|
||
def nearby_stations(lat, lon, station_type, radius_km) do
|
||
dlat = radius_km / @km_per_deg_lat
|
||
dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180))
|
||
|
||
Station
|
||
|> where([s], s.station_type == ^station_type)
|
||
|> where(
|
||
[s],
|
||
s.lat >= ^(lat - dlat) and s.lat <= ^(lat + dlat) and
|
||
s.lon >= ^(lon - dlon) and s.lon <= ^(lon + dlon)
|
||
)
|
||
|> Repo.all()
|
||
end
|
||
|
||
@spec sync_stations!() :: :ok
|
||
def sync_stations! do
|
||
asos =
|
||
for s <-
|
||
~w(AK AL AR AZ CA CO CT DE FL GA HI IA ID IL IN KS KY LA MA MD ME MI MN MO MS MT NC ND NE NH NJ NM NV NY OH OK OR PA RI SC SD TN TX UT VA VT WA WI WV WY),
|
||
do: "#{s}_ASOS"
|
||
|
||
for network <- asos ++ ["RAOB"] do
|
||
sync_network(network)
|
||
Process.sleep(200)
|
||
end
|
||
|
||
count = Repo.aggregate(Station, :count)
|
||
Logger.info("Weather stations sync complete: #{count} total")
|
||
:ok
|
||
end
|
||
|
||
defp sync_network(network) do
|
||
type = if String.contains?(network, "ASOS"), do: "asos", else: "sounding"
|
||
|
||
case IemClient.fetch_network(network) do
|
||
{:ok, stations} ->
|
||
now = DateTime.utc_now()
|
||
|
||
entries =
|
||
Enum.map(stations, fn s ->
|
||
%{
|
||
id: UUID.generate(),
|
||
station_code: s.station_code,
|
||
station_type: type,
|
||
name: s.name,
|
||
lat: s.lat,
|
||
lon: s.lon,
|
||
inserted_at: now,
|
||
updated_at: now
|
||
}
|
||
end)
|
||
|
||
Repo.insert_all(Station, entries,
|
||
on_conflict: :nothing,
|
||
conflict_target: [:station_code, :station_type]
|
||
)
|
||
|
||
Logger.info("Synced #{length(stations)} stations from #{network}")
|
||
|
||
{:error, e} ->
|
||
Logger.warning("Failed to sync #{network}: #{inspect(e)}")
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Flip `contacts.weather_status` from `:queued` to `:complete` for
|
||
every contact whose ±2h / 150km window now contains at least one
|
||
surface observation. Returns `{:ok, n}` where `n` is the number of
|
||
contacts advanced.
|
||
|
||
Background: `WeatherFetchWorker` upserts observations but has no
|
||
back-pointer to the contacts that triggered the fetch — the same
|
||
obs row satisfies many contacts. Previously the only place that
|
||
flipped `:queued → :complete` was `MicrowavepropWeb.ContactLive.Show`
|
||
on page view, which left thousands of contacts stuck in `:queued`
|
||
even after their data had landed. This reconciler closes that loop
|
||
as a single SQL UPDATE, invoked from the hourly enqueuer cron.
|
||
|
||
Radius encoded as a ±1.5° latitude band; the longitude band is
|
||
scaled by `1 / cos(lat)` so the box covers the same physical
|
||
east-west distance (~150 km) at every latitude. A fixed 1.5° lon
|
||
box collapses to ~110 km at lat 49° and would silently skip
|
||
observations the per-contact `weather_for_contact/2` query would
|
||
match.
|
||
"""
|
||
@spec reconcile_weather_statuses() :: {:ok, non_neg_integer()}
|
||
def reconcile_weather_statuses do
|
||
sql = """
|
||
UPDATE contacts c
|
||
SET weather_status = 'complete'
|
||
WHERE c.weather_status = 'queued'
|
||
AND c.pos1 IS NOT NULL
|
||
AND (
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM surface_observations o
|
||
JOIN weather_stations s ON s.id = o.station_id
|
||
WHERE o.observed_at >= c.qso_timestamp - interval '2 hours'
|
||
AND o.observed_at <= c.qso_timestamp + interval '2 hours'
|
||
AND s.lat BETWEEN ((c.pos1->>'lat')::float - 1.5)
|
||
AND ((c.pos1->>'lat')::float + 1.5)
|
||
AND s.lon BETWEEN ((c.pos1->>'lon')::float - 1.5 / GREATEST(cos(radians((c.pos1->>'lat')::float)), 0.01))
|
||
AND ((c.pos1->>'lon')::float + 1.5 / GREATEST(cos(radians((c.pos1->>'lat')::float)), 0.01))
|
||
)
|
||
OR (
|
||
c.pos2 IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM surface_observations o
|
||
JOIN weather_stations s ON s.id = o.station_id
|
||
WHERE o.observed_at >= c.qso_timestamp - interval '2 hours'
|
||
AND o.observed_at <= c.qso_timestamp + interval '2 hours'
|
||
AND s.lat BETWEEN ((c.pos2->>'lat')::float - 1.5)
|
||
AND ((c.pos2->>'lat')::float + 1.5)
|
||
AND s.lon BETWEEN ((c.pos2->>'lon')::float - 1.5 / GREATEST(cos(radians((c.pos2->>'lat')::float)), 0.01))
|
||
AND ((c.pos2->>'lon')::float + 1.5 / GREATEST(cos(radians((c.pos2->>'lat')::float)), 0.01))
|
||
)
|
||
)
|
||
)
|
||
"""
|
||
|
||
%{num_rows: n} = Repo.query!(sql)
|
||
{:ok, n}
|
||
end
|
||
|
||
# ── Solar Index ──
|
||
|
||
@spec upsert_solar_index(map()) :: {:ok, SolarIndex.t()} | {:error, Ecto.Changeset.t()}
|
||
def upsert_solar_index(attrs) do
|
||
%SolarIndex{}
|
||
|> SolarIndex.changeset(attrs)
|
||
|> Repo.insert(
|
||
on_conflict:
|
||
from(s in SolarIndex,
|
||
update: [
|
||
set: [
|
||
sfi: fragment("EXCLUDED.sfi"),
|
||
sfi_adjusted: fragment("EXCLUDED.sfi_adjusted"),
|
||
sunspot_number: fragment("EXCLUDED.sunspot_number"),
|
||
ap_index: fragment("EXCLUDED.ap_index"),
|
||
kp_values: fragment("EXCLUDED.kp_values"),
|
||
updated_at: fragment("EXCLUDED.updated_at")
|
||
]
|
||
],
|
||
where:
|
||
s.sfi != fragment("EXCLUDED.sfi") or
|
||
s.ap_index != fragment("EXCLUDED.ap_index")
|
||
),
|
||
conflict_target: [:date],
|
||
returning: true,
|
||
stale_error_field: :id
|
||
)
|
||
end
|
||
|
||
@doc "Batch upsert solar indices using insert_all in chunks of 500."
|
||
@spec upsert_solar_indices_batch([map()]) :: non_neg_integer()
|
||
def upsert_solar_indices_batch(records) do
|
||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||
|
||
records
|
||
|> Enum.chunk_every(500)
|
||
|> Enum.reduce(0, fn chunk, acc ->
|
||
entries =
|
||
Enum.map(chunk, fn attrs ->
|
||
%{
|
||
id: UUID.generate(),
|
||
date: attrs[:date] || attrs.date,
|
||
sfi: attrs[:sfi],
|
||
sfi_adjusted: attrs[:sfi_adjusted],
|
||
sunspot_number: attrs[:sunspot_number],
|
||
ap_index: attrs[:ap_index],
|
||
kp_values: attrs[:kp_values],
|
||
inserted_at: now,
|
||
updated_at: now
|
||
}
|
||
end)
|
||
|
||
{count, _} =
|
||
Repo.insert_all(SolarIndex, entries,
|
||
on_conflict:
|
||
from(s in SolarIndex,
|
||
update: [
|
||
set: [
|
||
sfi: fragment("EXCLUDED.sfi"),
|
||
sfi_adjusted: fragment("EXCLUDED.sfi_adjusted"),
|
||
sunspot_number: fragment("EXCLUDED.sunspot_number"),
|
||
ap_index: fragment("EXCLUDED.ap_index"),
|
||
kp_values: fragment("EXCLUDED.kp_values"),
|
||
updated_at: fragment("EXCLUDED.updated_at")
|
||
]
|
||
],
|
||
where:
|
||
s.sfi != fragment("EXCLUDED.sfi") or
|
||
s.ap_index != fragment("EXCLUDED.ap_index")
|
||
),
|
||
conflict_target: [:date]
|
||
)
|
||
|
||
acc + count
|
||
end)
|
||
end
|
||
|
||
@spec get_solar_index(Date.t()) :: SolarIndex.t() | nil
|
||
def get_solar_index(date) do
|
||
Repo.get_by(SolarIndex, date: date)
|
||
end
|
||
|
||
@spec existing_solar_dates() :: MapSet.t(Date.t())
|
||
def existing_solar_dates do
|
||
SolarIndex
|
||
|> select([s], s.date)
|
||
|> Repo.all()
|
||
|> MapSet.new()
|
||
end
|
||
|
||
# ── Private helpers ──
|
||
|
||
defp row_has_observed_at?(%{observed_at: %DateTime{}}), do: true
|
||
defp row_has_observed_at?(%{"observed_at" => %DateTime{}}), do: true
|
||
defp row_has_observed_at?(_), do: false
|
||
|
||
# IEM ASOS occasionally returns two rows with the same timestamp
|
||
# (e.g. routine + special METAR at the same minute). Passing both
|
||
# to insert_all triggers Postgres 21000 cardinality_violation.
|
||
# Keep the last occurrence — IEM's ordering is chronological, so
|
||
# the later row is the final correction.
|
||
defp dedupe_last_by_conflict_target(entries) do
|
||
entries
|
||
|> Enum.reverse()
|
||
|> Enum.uniq_by(&{&1.station_id, &1.observed_at})
|
||
|> Enum.reverse()
|
||
end
|
||
|
||
defp surface_observation_entry(row, station_id, now) do
|
||
%{
|
||
id: UUID.generate(),
|
||
station_id: station_id,
|
||
observed_at: fetch_row(row, :observed_at),
|
||
temp_f: fetch_row(row, :temp_f),
|
||
dewpoint_f: fetch_row(row, :dewpoint_f),
|
||
relative_humidity: fetch_row(row, :relative_humidity),
|
||
wind_speed_kts: fetch_row(row, :wind_speed_kts),
|
||
wind_direction_deg: fetch_row(row, :wind_direction_deg),
|
||
sea_level_pressure_mb: fetch_row(row, :sea_level_pressure_mb),
|
||
altimeter_setting: fetch_row(row, :altimeter_setting),
|
||
sky_condition: fetch_row(row, :sky_condition),
|
||
precip_1h_in: fetch_row(row, :precip_1h_in),
|
||
wx_codes: fetch_row(row, :wx_codes),
|
||
inserted_at: now,
|
||
updated_at: now
|
||
}
|
||
end
|
||
|
||
defp fetch_row(row, key) when is_atom(key) do
|
||
case Map.fetch(row, key) do
|
||
{:ok, value} -> value
|
||
:error -> Map.get(row, Atom.to_string(key))
|
||
end
|
||
end
|
||
end
|