prop/lib/microwaveprop/weather/surface.ex
Graham McIntire 079346a1b9
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m38s
fix: resolve all 281 credo issues across source and test files
- F-level (228): replace length/1 with Enum.count_until/2 or pattern
  matching; convert Enum.flat_map+if to Enum.filter+Enum.map; fix
  identity case in narr_client
- W-level (46): normalize dual atom/string key access in weather_layers,
  beacon_measurements, surface, skewt_live, contact_live/show; add
  :data_provider and weather-map assigns to ignored_assigns in credo
  config (consumed by child components credo can't trace); remove
  weak is_list assertion; remove explicit assert_receive timeout
- R-level (6): replace 'This module provides...' moduledocs with
  meaningful descriptions
- Also fix 4 compile-connected xref issues by deferring
  BandConfig.band_options() from module attribute to runtime

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:04:21 -05:00

453 lines
16 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.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
attrs = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
code = attrs["station_code"]
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 24288 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