prop/lib/microwaveprop/weather.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

216 lines
8.4 KiB
Elixir

defmodule Microwaveprop.Weather do
@moduledoc false
alias Ecto.UUID
alias Microwaveprop.Repo
alias Microwaveprop.Weather.GefsProfile
alias Microwaveprop.Weather.Grid
alias Microwaveprop.Weather.Hrrr
alias Microwaveprop.Weather.Iemre
alias Microwaveprop.Weather.Narr
alias Microwaveprop.Weather.Soundings
alias Microwaveprop.Weather.Surface
require Logger
# ── Delegates to sub-facades ──
# HRRR
defdelegate find_nearest_hrrr(lat, lon, timestamp), to: Hrrr
defdelegate hrrr_profiles_for_path(contact), to: Hrrr
defdelegate hrrr_profiles_for_contacts(contacts), to: Hrrr
defdelegate find_nearest_native_profile(lat, lon, timestamp), to: Hrrr
defdelegate best_profile_for_contact(contact), to: Hrrr
defdelegate hrrr_for_contact(contact), to: Hrrr
defdelegate hrrr_data_fully_present?(contact), to: Hrrr
defdelegate has_hrrr_profile?(lat, lon, valid_time), to: Hrrr
defdelegate hrrr_points_present_batch(points), to: Hrrr
defdelegate round_to_hrrr_grid(lat, lon), to: Hrrr
defdelegate purge_grid_point_profiles(), to: Hrrr
defdelegate upsert_hrrr_profile(attrs), to: Hrrr
defdelegate upsert_hrrr_profiles_batch(profiles, opts \\ []), to: Hrrr
defdelegate nearest_native_duct_ghz(lat, lon, timestamp), to: Hrrr
defdelegate nearest_native_duct_info(lat, lon, timestamp), to: Hrrr
defdelegate backfill_hrrr_scalars(opts \\ []), to: Hrrr
defdelegate reconcile_hrrr_statuses(), to: Hrrr
defdelegate profiles_along_path(contact), to: Hrrr
# NARR
defdelegate narr_profiles_for_path(contact), to: Narr
defdelegate find_nearest_narr(lat, lon, timestamp), to: Narr
defdelegate narr_for_contact(contact), to: Narr
# IEMRE
defdelegate iemre_for_contact(contact), to: Iemre
defdelegate iemre_for_path(contact), to: Iemre
defdelegate find_nearest_iemre(lat, lon, timestamp), to: Iemre
defdelegate round_to_iemre_grid(lat, lon), to: Iemre
defdelegate upsert_iemre_observation(attrs), to: Iemre
defdelegate has_iemre_observation?(lat, lon, date), to: Iemre
defdelegate reconcile_iemre_statuses(), to: Iemre
# Soundings
defdelegate upsert_sounding(station, attrs), to: Soundings
defdelegate has_sounding?(station_id, observed_at), to: Soundings
defdelegate station_ids_with_soundings(station_ids, sounding_times), to: Soundings
defdelegate sounding_times_around(dt), to: Soundings
defdelegate weather_for_contact(contact_params, opts \\ []), to: Soundings
defdelegate soundings_with_widening_radius(params), to: Soundings
defdelegate nearest_sounding_to(lat, lon, timestamp, opts \\ []), to: Soundings
# Surface
defdelegate find_or_create_station(attrs), to: Surface
defdelegate upsert_surface_observation(station, attrs), to: Surface
defdelegate upsert_surface_observations(station, rows), to: Surface
defdelegate has_surface_observations?(station_id, start_dt, end_dt), to: Surface
defdelegate station_day_covered?(station_id, date), to: Surface
defdelegate station_day_pairs_covered(pairs), to: Surface
defdelegate station_ids_with_surface_observations(station_ids, start_dt, end_dt), to: Surface
defdelegate nearby_stations(lat, lon, station_type, radius_km), to: Surface
defdelegate sync_stations!(), to: Surface
defdelegate reconcile_weather_statuses(), to: Surface
defdelegate upsert_solar_index(attrs), to: Surface
defdelegate upsert_solar_indices_batch(records), to: Surface
defdelegate get_solar_index(date), to: Surface
defdelegate existing_solar_dates(), to: Surface
# Grid
defdelegate latest_grid_valid_time(), to: Grid
defdelegate latest_weather_grid(bounds), to: Grid
defdelegate load_weather_grid(bounds), to: Grid
defdelegate available_weather_valid_times(), to: Grid
defdelegate available_hrdps_valid_times(), to: Grid
defdelegate weather_grid_hrdps_at(valid_time, bounds), to: Grid
defdelegate weather_grid_at(valid_time, bounds), to: Grid
defdelegate warm_grid_cache_and_broadcast(valid_time), to: Grid
defdelegate warm_grid_cache_from_latest_profile(), to: Grid
defdelegate materialize_scalar_file(valid_time), to: Grid
defdelegate build_grid_cache_rows(grid_data, valid_time, bounds \\ nil), to: Grid
defdelegate weather_point_detail(lat, lon, valid_time), to: Grid
# ── Remaining inline: GEFS ──
@spec upsert_gefs_profile(map()) :: {:ok, GefsProfile.t()} | {:error, Ecto.Changeset.t()}
def upsert_gefs_profile(attrs) do
changeset = GefsProfile.changeset(%GefsProfile{}, attrs)
if changeset.valid? do
Repo.insert(changeset,
on_conflict: :nothing,
conflict_target: [:lat, :lon, :valid_time]
)
else
{:error, changeset}
end
end
@spec upsert_gefs_profiles_batch([map()]) :: {non_neg_integer(), nil}
def upsert_gefs_profiles_batch(profiles) do
Microwaveprop.Instrument.span(
[:db, :upsert_gefs_profiles],
%{count: length(profiles)},
fn -> do_upsert_gefs_profiles_batch(profiles) end
)
end
defp do_upsert_gefs_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 ->
Map.merge(attrs, %{
id: UUID.generate(),
inserted_at: now,
updated_at: now
})
end)
{count, rows} =
Repo.insert_all(GefsProfile, entries,
on_conflict: :nothing,
conflict_target: [:lat, :lon, :valid_time]
)
{total_count + count, rows}
end)
end
# ── DB Maintenance ──
@doc """
Kick off `ANALYZE` on every public-schema table that hasn't been
auto-analyzed recently. Returns immediately with the list of tables
it will visit; the actual work runs in a supervised Task on the
current node so a broken `kubectl exec` doesn't abort it mid-way.
Callable from a release shell:
bin/microwaveprop rpc 'Microwaveprop.Weather.analyze_all()'
Surfaces the HRRR partition problem: most partitions have zero
analyze stats, so the planner picks nested-loop joins that scan
81 M rows. Running ANALYZE once gives it live_tup and column
histograms to work with.
`skip_recent` skips anything auto-analyzed in the last N seconds
(default 6 h) so re-running is cheap. Progress is logged via
`Logger.info` — tail `kubectl -n prop logs deploy/prop-backfill`
to watch.
"""
@spec analyze_all(keyword()) :: %{queued: non_neg_integer(), tables: [String.t()]}
def analyze_all(opts \\ []) do
skip_recent = Keyword.get(opts, :skip_recent_seconds, 6 * 3600)
# `last_autoanalyze` is only set by the autovacuum daemon;
# `last_analyze` is set by manual ANALYZE (including our Task
# below). Take the newer of the two — otherwise a re-run within
# `skip_recent` would re-analyze everything we just did.
q = """
SELECT relname
FROM pg_stat_user_tables
WHERE schemaname = 'public'
AND COALESCE(
GREATEST(last_analyze, last_autoanalyze),
'epoch'::timestamptz
) < now() - ($1 || ' seconds')::interval
ORDER BY n_live_tup ASC
"""
%{rows: rows} = Repo.query!(q, [Integer.to_string(skip_recent)])
tables = Enum.map(rows, fn [n] -> n end)
# Fire-and-forget: run each ANALYZE on the pod's own node so the
# caller returns before any single query finishes. Small tables
# first keeps progress visible early.
_ =
Task.Supervisor.start_child({:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}}, fn ->
Enum.each(tables, fn t ->
Logger.info("Weather.analyze_all: ANALYZE #{t}")
started = System.monotonic_time(:millisecond)
try do
_ = Repo.query!("ANALYZE #{quote_ident(t)}", [], timeout: :infinity)
elapsed = System.monotonic_time(:millisecond) - started
Logger.info("Weather.analyze_all: ANALYZE #{t} done in #{elapsed}ms")
rescue
e -> Logger.warning("Weather.analyze_all: ANALYZE #{t} failed: #{inspect(e)}")
end
end)
Logger.info("Weather.analyze_all: complete (#{length(tables)} tables)")
end)
%{queued: length(tables), tables: tables}
end
# Defensive: pg_stat_user_tables returns legitimate identifiers from
# the catalog, but we still quote just in case a partition contains a
# character the shell would interpret.
defp quote_ident(name) when is_binary(name) do
escaped = String.replace(name, ~s("), ~s(""))
~s("#{escaped}")
end
end