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)
568 lines
20 KiB
Elixir
568 lines
20 KiB
Elixir
defmodule Microwaveprop.Weather.Grid do
|
||
@moduledoc false
|
||
|
||
import Ecto.Query
|
||
|
||
alias Microwaveprop.Propagation.ProfilesFile
|
||
alias Microwaveprop.Repo
|
||
alias Microwaveprop.Weather.GridCache
|
||
alias Microwaveprop.Weather.HrrrProfile
|
||
alias Microwaveprop.Weather.ScalarFile
|
||
alias Microwaveprop.Weather.SoundingParams
|
||
alias Microwaveprop.Weather.WeatherLayers
|
||
|
||
require Logger
|
||
|
||
@hrdps_nearest_window_seconds 6 * 3600
|
||
|
||
# Cap on cached valid_times. Each entry is a chunked map of the full
|
||
# ~92k-cell CONUS grid with 22 fields per cell, ~32 MiB compressed in
|
||
# ETS. The previous cap of 24 meant the cache could hoard up to
|
||
# ~768 MiB on a single pod against a 6 GiB memory limit (the headroom
|
||
# that disappeared in the 2026-05-03 OOM cascade).
|
||
#
|
||
# 8 covers the current hour plus the next ~7 forecast hours — the
|
||
# typical user scrub window. Hours beyond 8 fall back to a disk read
|
||
# (`ScalarFile.read_bounds`) which is sub-100 ms per file, so rare
|
||
# deep-forecast scrubs pay a one-shot latency hit instead of every
|
||
# pod permanently parking ~500 MiB on data the user almost never
|
||
# looks at. Worst case ETS spend drops from 768 MiB → 256 MiB.
|
||
@grid_cache_valid_time_cap 8
|
||
|
||
@spec latest_grid_valid_time() :: DateTime.t() | nil
|
||
def latest_grid_valid_time do
|
||
cond do
|
||
vt = GridCache.latest_valid_time() -> vt
|
||
vt = ProfilesFile.latest_valid_time() -> vt
|
||
true -> latest_grid_valid_time_from_db()
|
||
end
|
||
end
|
||
|
||
# Last-resort fallback for historical data sitting in the legacy
|
||
# hrrr_profiles table. PropagationGridWorker no longer writes
|
||
# grid-point rows there, so in steady state this returns nil and
|
||
# the ProfilesFile fallback above is the real source of truth.
|
||
defp latest_grid_valid_time_from_db do
|
||
Repo.one(
|
||
from(h in HrrrProfile,
|
||
where: h.is_grid_point == true,
|
||
select: max(h.valid_time)
|
||
)
|
||
)
|
||
end
|
||
|
||
@doc """
|
||
Cache-only read for the /weather LiveView mount hot path. Returns whatever
|
||
is in `GridCache` for the latest valid_time and fires a deduped background
|
||
fill task on a miss instead of blocking. The async task broadcasts
|
||
`weather:updated` when done, triggering every connected LiveView to refresh.
|
||
|
||
Callers that genuinely need synchronous data (tests, scripts) should use
|
||
`load_weather_grid/1` instead.
|
||
"""
|
||
@spec latest_weather_grid(%{optional(String.t()) => float()} | nil) :: [map()]
|
||
def latest_weather_grid(bounds) do
|
||
case latest_grid_valid_time() do
|
||
nil ->
|
||
[]
|
||
|
||
latest_vt ->
|
||
case GridCache.fetch_bounds(latest_vt, bounds) do
|
||
{:ok, rows} ->
|
||
rows
|
||
|
||
:miss ->
|
||
kickoff_async_grid_fill(latest_vt)
|
||
[]
|
||
end
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Synchronous cache-or-disk read. Blocks for ~1s on a cold cache while
|
||
`ProfilesFile.read/1` loads the latest grid from `/data/profiles`.
|
||
Used by tests and by `weather_point_detail/3` fallbacks. LiveView
|
||
callers should prefer `latest_weather_grid/1`.
|
||
"""
|
||
@spec load_weather_grid(%{optional(String.t()) => float()} | nil) :: [map()]
|
||
def load_weather_grid(bounds) do
|
||
case latest_grid_valid_time() do
|
||
nil ->
|
||
[]
|
||
|
||
latest_vt ->
|
||
case GridCache.fetch_bounds(latest_vt, bounds) do
|
||
{:ok, rows} ->
|
||
rows
|
||
|
||
:miss ->
|
||
full = load_grid_rows_for(latest_vt)
|
||
GridCache.put(latest_vt, full)
|
||
filter_weather_bounds(full, bounds)
|
||
end
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
All persisted weather valid_times sorted ascending. The grid worker
|
||
writes a ProfilesFile for every forecast hour (f00..f18 hourly, f21..f48 3-hourly), and the
|
||
derived `ScalarFile` mirrors that for any hour that has been
|
||
materialized. We union both so the timeline survives an aggressive
|
||
retention sweep on either side as long as one artifact remains.
|
||
"""
|
||
@spec available_weather_valid_times() :: [DateTime.t()]
|
||
def available_weather_valid_times do
|
||
scalar_times = ScalarFile.list_valid_times()
|
||
profile_times = ProfilesFile.list_valid_times()
|
||
|
||
(scalar_times ++ profile_times)
|
||
|> Enum.uniq()
|
||
|> Enum.sort(DateTime)
|
||
end
|
||
|
||
@doc """
|
||
All persisted HRDPS valid_times (i.e. those with a `<iso>.hrdps`
|
||
scalar dir on disk) sorted ascending. Backs the `/weather-ca`
|
||
timeline.
|
||
"""
|
||
@spec available_hrdps_valid_times() :: [DateTime.t()]
|
||
def available_hrdps_valid_times do
|
||
ScalarFile.list_valid_times_hrdps()
|
||
end
|
||
|
||
@doc """
|
||
HRDPS-only counterpart to `weather_grid_at/2`. Reads from the
|
||
`<vt>.hrdps` scalar dir and skips HRRR completely. Bypasses GridCache
|
||
because the cache mixes HRRR + HRDPS rows by design — caching this
|
||
separately would double the per-pod memory budget for marginal benefit
|
||
(Canadian viewport reads are infrequent compared to CONUS).
|
||
|
||
The /weather (dual-source) timeline picks valid_times from HRRR's
|
||
hourly cadence, but HRDPS publishes 4×/day with multi-hour latency,
|
||
so the requested time will frequently miss any on-disk HRDPS dir.
|
||
Snapping to the nearest available HRDPS time within a 6 h window
|
||
keeps the Canadian overlay rendering slightly stale instead of
|
||
disappearing entirely. /weather-ca picks times from HRDPS-only listings
|
||
so the exact time always matches and the snap is a no-op.
|
||
"""
|
||
@spec weather_grid_hrdps_at(DateTime.t(), %{optional(String.t()) => float()} | nil) :: [map()]
|
||
def weather_grid_hrdps_at(%DateTime{} = valid_time, bounds) do
|
||
ScalarFile.read_bounds_hrdps_nearest(valid_time, bounds, @hrdps_nearest_window_seconds)
|
||
end
|
||
|
||
@doc """
|
||
Read the weather grid for a specific `valid_time` and bounds. Like
|
||
`load_weather_grid/1` but takes the valid_time explicitly so the
|
||
timeline can scrub to any forecast hour, not just the analysis hour.
|
||
Returns `[]` if no profile file exists for that valid_time.
|
||
|
||
Deliberately does NOT write forecast-hour grids back into `GridCache`
|
||
on a miss: caching 48 forecast hours × 92k points would add ~300 MB
|
||
per pod. The ProfilesFile read is a single ~2 MB ETF decode per scrub,
|
||
which is fast enough for a user click.
|
||
"""
|
||
@spec weather_grid_at(DateTime.t(), %{optional(String.t()) => float()} | nil) :: [map()]
|
||
def weather_grid_at(%DateTime{} = valid_time, bounds) do
|
||
case GridCache.fetch_bounds(valid_time, bounds) do
|
||
{:ok, rows} ->
|
||
rows
|
||
|
||
:miss ->
|
||
# Once a scalar file exists for `valid_time` it's the source of
|
||
# truth — even an empty viewport read (e.g. over the ocean) is
|
||
# authoritative, so don't fall back to a full ProfilesFile decode
|
||
# in that case.
|
||
if ScalarFile.exists?(valid_time) do
|
||
read_via_scalar(valid_time, bounds)
|
||
else
|
||
read_and_derive_grid(valid_time, bounds)
|
||
end
|
||
end
|
||
end
|
||
|
||
defp read_via_scalar(valid_time, bounds) do
|
||
fill_grid_cache_from_scalar(valid_time)
|
||
|
||
case GridCache.fetch_bounds(valid_time, bounds) do
|
||
{:ok, rows} -> rows
|
||
:miss -> ScalarFile.read_bounds(valid_time, bounds)
|
||
end
|
||
end
|
||
|
||
# Hydrate GridCache from the on-disk ScalarFile so concurrent viewport
|
||
# reads for the same valid_time don't each gunzip+msgpack-decode the
|
||
# same chunk files. Only the caller that wins `claim_fill` does the
|
||
# work; losers wait briefly for the ETS write to land.
|
||
defp fill_grid_cache_from_scalar(valid_time) do
|
||
lock_key = {:scalar_to_grid_cache, valid_time}
|
||
|
||
if GridCache.claim_fill(lock_key) do
|
||
try do
|
||
rows = ScalarFile.read_bounds(valid_time, nil)
|
||
|
||
if rows != [] do
|
||
GridCache.put(valid_time, rows)
|
||
_ = GridCache.prune_keep_latest(@grid_cache_valid_time_cap)
|
||
end
|
||
after
|
||
GridCache.release_fill(lock_key)
|
||
end
|
||
else
|
||
wait_for_grid_cache(valid_time)
|
||
end
|
||
end
|
||
|
||
defp wait_for_grid_cache(valid_time) do
|
||
Enum.reduce_while(1..50, :miss, fn _, _ ->
|
||
case GridCache.fetch(valid_time) do
|
||
{:ok, _} ->
|
||
{:halt, :ok}
|
||
|
||
:miss ->
|
||
Process.sleep(20)
|
||
{:cont, :miss}
|
||
end
|
||
end)
|
||
end
|
||
|
||
# Cold path: ScalarFile didn't have anything for this valid_time, so
|
||
# decode the raw ProfilesFile and derive the requested viewport. Kicks
|
||
# off a background materialization of the full scalar file so the
|
||
# next read hits the cheap path.
|
||
defp read_and_derive_grid(valid_time, bounds) do
|
||
case ProfilesFile.read(valid_time) do
|
||
{:ok, grid_data} ->
|
||
# Filter-before-derive: only derive the points inside the
|
||
# viewport instead of all 92k CONUS grid points. On a DFW
|
||
# viewport that's ~20× less `SoundingParams.derive` work
|
||
# per timeline scrub.
|
||
rows = build_grid_cache_rows(grid_data, valid_time, bounds)
|
||
kickoff_async_scalar_materialize(valid_time, grid_data)
|
||
rows
|
||
|
||
{:error, _} ->
|
||
[]
|
||
end
|
||
end
|
||
|
||
# Materialize the full ScalarFile for `valid_time` once per node,
|
||
# asynchronously. Reuses GridCache.claim_fill so concurrent
|
||
# `weather_grid_at` callers don't trigger N derivations of the same
|
||
# 92k-cell grid. Lock key namespaced so it doesn't collide with the
|
||
# GridCache cold-fill claim for the same valid_time.
|
||
defp kickoff_async_scalar_materialize(valid_time, grid_data) do
|
||
if ScalarFile.exists?(valid_time) do
|
||
:ok
|
||
else
|
||
lock_key = {:scalar_materialize, valid_time}
|
||
|
||
_ =
|
||
if GridCache.claim_fill(lock_key) do
|
||
{:ok, _pid} =
|
||
Task.start(fn ->
|
||
try do
|
||
rows = build_grid_cache_rows(grid_data, valid_time)
|
||
ScalarFile.write!(valid_time, rows)
|
||
|
||
Logger.info("Weather.scalar_file materialized valid_time=#{valid_time} rows=#{length(rows)}")
|
||
rescue
|
||
e ->
|
||
Logger.error("Weather.scalar_file materialize failed valid_time=#{valid_time} #{inspect(e)}")
|
||
after
|
||
GridCache.release_fill(lock_key)
|
||
end
|
||
end)
|
||
end
|
||
|
||
:ok
|
||
end
|
||
end
|
||
|
||
# Build derived GridCache rows for a valid_time from whichever
|
||
# source has data: the persisted ProfilesFile first (hot path in
|
||
# steady state), then the legacy hrrr_profiles table (historical
|
||
# data only).
|
||
defp load_grid_rows_for(valid_time) do
|
||
case ProfilesFile.read(valid_time) do
|
||
{:ok, grid_data} -> build_grid_cache_rows(grid_data, valid_time)
|
||
{:error, _} -> []
|
||
end
|
||
end
|
||
|
||
defp filter_weather_bounds(rows, nil), do: rows
|
||
|
||
defp filter_weather_bounds(rows, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
||
Enum.filter(rows, fn %{lat: lat, lon: lon} ->
|
||
lat >= s and lat <= n and lon >= w and lon <= e
|
||
end)
|
||
end
|
||
|
||
defp kickoff_async_grid_fill(valid_time) do
|
||
_ =
|
||
if GridCache.claim_fill(valid_time) do
|
||
{:ok, _pid} =
|
||
Task.start(fn ->
|
||
try do
|
||
Logger.info("Weather.grid_cache async fill starting for #{valid_time}")
|
||
warm_grid_cache_and_broadcast(valid_time)
|
||
|
||
_ =
|
||
Phoenix.PubSub.broadcast(
|
||
Microwaveprop.PubSub,
|
||
"weather:updated",
|
||
{:weather_updated, valid_time}
|
||
)
|
||
|
||
Logger.info("Weather.grid_cache async fill complete for #{valid_time}")
|
||
rescue
|
||
e ->
|
||
Logger.error("Weather.grid_cache async fill failed: #{inspect(e)}")
|
||
after
|
||
GridCache.release_fill(valid_time)
|
||
end
|
||
end)
|
||
end
|
||
|
||
:ok
|
||
end
|
||
|
||
@doc """
|
||
Eagerly populate the `GridCache` with the full CONUS weather grid for
|
||
`valid_time` and broadcast it to every node in the cluster. Used by the cold
|
||
cache fill path (`kickoff_async_grid_fill/1`) — prefer
|
||
`build_grid_cache_rows/2` inside `PropagationGridWorker`, which already has
|
||
the in-memory grid data and avoids the ~20s JSONB round trip.
|
||
"""
|
||
@spec warm_grid_cache_and_broadcast(DateTime.t()) :: :ok
|
||
def warm_grid_cache_and_broadcast(valid_time) do
|
||
rows = load_grid_rows_for(valid_time)
|
||
GridCache.broadcast_put(valid_time, rows)
|
||
persist_scalar_file(valid_time, rows)
|
||
:ok
|
||
end
|
||
|
||
@doc """
|
||
Warm the local `GridCache` from the latest persisted `ProfilesFile`
|
||
on pod startup. Makes `/weather` usable immediately after a deploy
|
||
instead of waiting for the next hourly PropagationGridWorker run.
|
||
Local put only — every node reads the same NFS mount so no need to
|
||
broadcast.
|
||
"""
|
||
@spec warm_grid_cache_from_latest_profile() :: :ok
|
||
def warm_grid_cache_from_latest_profile do
|
||
case ProfilesFile.latest_valid_time() do
|
||
nil ->
|
||
:ok
|
||
|
||
valid_time ->
|
||
try do
|
||
rows = load_grid_rows_for(valid_time)
|
||
GridCache.put(valid_time, rows)
|
||
persist_scalar_file(valid_time, rows)
|
||
Logger.info("Weather: warmed GridCache from ProfilesFile for #{valid_time} (#{length(rows)} rows)")
|
||
rescue
|
||
e ->
|
||
Logger.warning("Weather: ProfilesFile warm failed: #{inspect(e)}")
|
||
end
|
||
|
||
:ok
|
||
end
|
||
end
|
||
|
||
# Persist a derived grid as a ScalarFile so subsequent `/weather` reads
|
||
# don't have to re-decode the raw ProfilesFile or re-run
|
||
# `SoundingParams.derive` + `WeatherLayers.derive`. Best-effort: an NFS
|
||
# write failure is logged but never fatal — the cold-derive path keeps
|
||
# working.
|
||
defp persist_scalar_file(_valid_time, []), do: :ok
|
||
|
||
defp persist_scalar_file(valid_time, rows) do
|
||
ScalarFile.write!(valid_time, rows)
|
||
:ok
|
||
rescue
|
||
e ->
|
||
Logger.warning("Weather: ScalarFile.write failed valid_time=#{valid_time} #{inspect(e)}")
|
||
:ok
|
||
end
|
||
|
||
@doc """
|
||
Materialize the `ScalarFile` for `valid_time` from the on-disk
|
||
`ProfilesFile`. Idempotent — if a scalar file already exists, returns
|
||
`:ok` without re-deriving. Used by `NotifyListener` to pre-warm scalar
|
||
artifacts the moment the Rust propagation pipeline fires
|
||
`propagation_ready`, so the first `/weather` reader of a new forecast
|
||
hour never falls back to the slow `ProfilesFile.read/1` + per-cell
|
||
derive path.
|
||
|
||
Synchronous; callers should run this inside a `Task` if they need
|
||
not to block (the listener does).
|
||
"""
|
||
@spec materialize_scalar_file(DateTime.t()) :: :ok
|
||
def materialize_scalar_file(%DateTime{} = valid_time) do
|
||
if ScalarFile.exists?(valid_time) do
|
||
:ok
|
||
else
|
||
try do
|
||
rows = load_grid_rows_for(valid_time)
|
||
persist_scalar_file(valid_time, rows)
|
||
Logger.info("Weather.materialize_scalar_file vt=#{valid_time} rows=#{length(rows)}")
|
||
:ok
|
||
rescue
|
||
e ->
|
||
Logger.warning("Weather.materialize_scalar_file failed vt=#{valid_time} #{inspect(e)}")
|
||
:ok
|
||
end
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Build derived weather grid cache rows directly from an in-memory HRRR
|
||
`grid_data` map. Used by the cold-cache fill path after `ProfilesFile.read/1`
|
||
returns the grid written by the Rust worker.
|
||
|
||
`grid_data` is `%{{lat, lon} => profile_map}` as produced by
|
||
`ProfilesFile.read/1`. Each row is pushed through `derive_and_clean/1`
|
||
to compute the derived fields consumed by the weather map LiveView.
|
||
"""
|
||
@spec build_grid_cache_rows(
|
||
%{{float(), float()} => map()},
|
||
DateTime.t(),
|
||
%{optional(String.t()) => float()} | nil
|
||
) :: [map()]
|
||
def build_grid_cache_rows(grid_data, valid_time, bounds \\ nil) do
|
||
grid_data
|
||
|> filter_grid_data_bounds(bounds)
|
||
|> Enum.flat_map(fn {{lat, lon}, profile} ->
|
||
build_grid_cache_row(lat, lon, profile, valid_time)
|
||
end)
|
||
end
|
||
|
||
defp filter_grid_data_bounds(grid_data, nil), do: grid_data
|
||
|
||
defp filter_grid_data_bounds(grid_data, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
||
:maps.filter(fn {lat, lon}, _ -> lat >= s and lat <= n and lon >= w and lon <= e end, grid_data)
|
||
end
|
||
|
||
defp build_grid_cache_row(lat, lon, profile, valid_time) do
|
||
temp_c = profile[:surface_temp_c]
|
||
|
||
if is_nil(temp_c) or temp_c < -80 or temp_c > 60 do
|
||
[]
|
||
else
|
||
sounding = derive_sounding(profile[:profile])
|
||
|
||
row = %{
|
||
lat: lat,
|
||
lon: lon,
|
||
valid_time: valid_time,
|
||
temperature: temp_c,
|
||
dewpoint_depression: depression(temp_c, profile[:surface_dewpoint_c]),
|
||
bl_height: profile[:hpbl_m],
|
||
pwat: profile[:pwat_mm],
|
||
refractivity_gradient:
|
||
prefer(profile, :min_refractivity_gradient, sounding[:min_refractivity_gradient]) ||
|
||
profile[:native_min_gradient],
|
||
ducting: prefer(profile, :ducting_detected, sounding[:ducting_detected]),
|
||
surface_pressure_mb: profile[:surface_pressure_mb],
|
||
surface_temp_c: temp_c,
|
||
surface_dewpoint_c: profile[:surface_dewpoint_c],
|
||
surface_refractivity: prefer(profile, :surface_refractivity, sounding[:surface_refractivity]),
|
||
profile: profile[:profile] || [],
|
||
duct_characteristics: prefer(profile, :duct_characteristics, sounding[:duct_characteristics])
|
||
}
|
||
|
||
[derive_and_clean(row)]
|
||
end
|
||
end
|
||
|
||
# Fetch `key` from `profile` verbatim when present (including `false`,
|
||
# `0`, or `[]`); only fall through to the derived value when the key is
|
||
# absent. `profile[key] || default` would discard legitimate `false` /
|
||
# `0` as if they weren't set.
|
||
defp prefer(profile, key, default) do
|
||
case Map.fetch(profile, key) do
|
||
{:ok, value} -> value
|
||
:error -> default
|
||
end
|
||
end
|
||
|
||
defp derive_sounding(profile) when is_list(profile) and length(profile) >= 3 do
|
||
SoundingParams.derive(profile) || %{}
|
||
end
|
||
|
||
defp derive_sounding(_), do: %{}
|
||
|
||
defp depression(_, nil), do: nil
|
||
defp depression(t, d), do: t - d
|
||
|
||
@spec weather_point_detail(float(), float(), DateTime.t()) :: map() | nil
|
||
def weather_point_detail(lat, lon, valid_time) do
|
||
step = 0.125
|
||
snapped_lat = Float.round(Float.round(lat / step) * step, 3)
|
||
snapped_lon = Float.round(Float.round(lon / step) * step, 3)
|
||
|
||
case GridCache.fetch_point(valid_time, snapped_lat, snapped_lon) do
|
||
{:ok, row} -> row
|
||
:miss -> point_detail_off_cache(valid_time, snapped_lat, snapped_lon)
|
||
end
|
||
end
|
||
|
||
defp point_detail_off_cache(valid_time, snapped_lat, snapped_lon) do
|
||
case weather_point_detail_from_profiles(valid_time, snapped_lat, snapped_lon) do
|
||
nil -> weather_point_detail_from_db(valid_time, snapped_lat, snapped_lon)
|
||
row -> row
|
||
end
|
||
end
|
||
|
||
# Derive a single GridCache-shaped row from a persisted ProfilesFile
|
||
# entry for `(valid_time, lat, lon)`. Returns nil when the file
|
||
# doesn't exist or the point has no profile.
|
||
defp weather_point_detail_from_profiles(valid_time, snapped_lat, snapped_lon) do
|
||
case ProfilesFile.read_point(valid_time, snapped_lat, snapped_lon) do
|
||
nil ->
|
||
nil
|
||
|
||
profile ->
|
||
case build_grid_cache_rows(%{{snapped_lat, snapped_lon} => profile}, valid_time) do
|
||
[row] -> row
|
||
_ -> nil
|
||
end
|
||
end
|
||
end
|
||
|
||
defp weather_point_detail_from_db(valid_time, snapped_lat, snapped_lon) do
|
||
from(h in HrrrProfile,
|
||
where: h.lat == ^snapped_lat and h.lon == ^snapped_lon and h.valid_time == ^valid_time,
|
||
select: %{
|
||
lat: h.lat,
|
||
lon: h.lon,
|
||
valid_time: h.valid_time,
|
||
temperature: h.surface_temp_c,
|
||
dewpoint_depression: fragment("? - ?", h.surface_temp_c, h.surface_dewpoint_c),
|
||
bl_height: h.hpbl_m,
|
||
pwat: h.pwat_mm,
|
||
refractivity_gradient: h.min_refractivity_gradient,
|
||
ducting: h.ducting_detected,
|
||
surface_pressure_mb: h.surface_pressure_mb,
|
||
surface_temp_c: h.surface_temp_c,
|
||
surface_dewpoint_c: h.surface_dewpoint_c,
|
||
surface_refractivity: h.surface_refractivity,
|
||
profile: h.profile,
|
||
duct_characteristics: h.duct_characteristics
|
||
}
|
||
)
|
||
|> Repo.one()
|
||
|> then(fn
|
||
nil -> nil
|
||
row -> derive_and_clean(row)
|
||
end)
|
||
end
|
||
|
||
defp derive_and_clean(row) do
|
||
derived = WeatherLayers.derive(row)
|
||
|
||
row
|
||
|> Map.merge(derived)
|
||
|> Map.drop([:profile, :duct_characteristics, :surface_temp_c, :surface_dewpoint_c])
|
||
end
|
||
end
|