feat(weather): nearest_sounding_to helper for path calculator

Spatial + temporal nearest-RAOB lookup (defaults to 300 km / ±3 h)
joining weather_stations → soundings. Returns the full sounding row
so callers can read the derived ducting_detected / min_refractivity_
gradient fields that HRRR's pressure-level product systematically
under-reads for thin surface ducts.

Prep for the path-calculator enrichment: a sounding within range at
the path midpoint gives an independent duct signal to cross-check
the nine HRRR samples already taken along the path.
This commit is contained in:
Graham McIntire 2026-04-18 10:46:35 -05:00
parent 622edee180
commit 18d16b0ad6
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 91 additions and 0 deletions

View file

@ -795,6 +795,54 @@ defmodule Microwaveprop.Weather do
end
end
@doc """
Nearest sounding to (`lat`, `lon`) within `radius_km` km and a ±3-hour
window around `timestamp`. Joins `weather_stations` to `soundings` so
the caller gets the raw sounding row (station_id set, derived duct
fields populated) back.
Returns `{:ok, sounding}` or `{:error, :not_found}`. Use this in the
path calculator to surface the nearest RAOB's `ducting_detected` flag
as an independent check on HRRR's pressure-level duct signal, which
under-reads thin surface ducts.
"""
@spec nearest_sounding_to(float(), float(), DateTime.t(), keyword()) ::
{:ok, Sounding.t()} | {:error, :not_found}
def nearest_sounding_to(lat, lon, timestamp, opts \\ []) do
radius_km = Keyword.get(opts, :radius_km, 300)
hours = Keyword.get(opts, :hours, 3)
# 1 deg lat ≈ 111 km; lon scaled by cos(lat).
dlat = radius_km / 111.0
dlon = radius_km / (111.0 * max(0.1, :math.cos(lat * :math.pi() / 180.0)))
time_start = DateTime.add(timestamp, -hours * 3600, :second)
time_end = DateTime.add(timestamp, hours * 3600, :second)
from(s in Sounding,
join: station in assoc(s, :station),
where:
station.lat >= ^(lat - dlat) and station.lat <= ^(lat + dlat) and
station.lon >= ^(lon - dlon) and station.lon <= ^(lon + dlon) and
s.observed_at >= ^time_start and s.observed_at <= ^time_end,
order_by:
fragment(
"SQRT(POW(? - ?, 2) + POW(? - ?, 2)) + ABS(EXTRACT(EPOCH FROM ? - ?)) / 86400.0",
station.lat,
^lat,
station.lon,
^lon,
s.observed_at,
^timestamp
),
limit: 1
)
|> Repo.one()
|> case do
nil -> {:error, :not_found}
sounding -> {:ok, sounding}
end
end
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: HrrrProfile.t() | nil
def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07

View file

@ -265,6 +265,49 @@ defmodule Microwaveprop.WeatherTest do
end
end
describe "nearest_sounding_to/3" do
@profile [
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0},
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0}
]
setup do
{:ok, station} = Weather.find_or_create_station(@sounding_station_attrs)
{:ok, sounding} =
Weather.upsert_sounding(station, %{
observed_at: ~U[2026-03-28 12:00:00Z],
profile: @profile,
level_count: 2,
ducting_detected: true,
min_refractivity_gradient: -250.0
})
%{station: station, sounding: sounding}
end
test "returns the closest sounding within spatial + temporal window", %{sounding: sounding} do
# Roughly 30 km north of Fort Worth sounding station, 1h after the obs.
assert {:ok, found} =
Weather.nearest_sounding_to(33.10, -97.30, ~U[2026-03-28 13:00:00Z])
assert found.id == sounding.id
assert found.ducting_detected == true
end
test "returns {:error, :not_found} when no sounding is in range" do
# Far outside the default 300 km / ±3h window.
assert {:error, :not_found} =
Weather.nearest_sounding_to(45.0, -80.0, ~U[2026-03-28 12:00:00Z])
end
test "returns {:error, :not_found} when the sounding is outside the time window" do
# Same location as the sounding, 8 hours later — outside ±3h.
assert {:error, :not_found} =
Weather.nearest_sounding_to(32.83, -97.30, ~U[2026-03-28 20:00:00Z])
end
end
describe "existing_solar_dates/0" do
test "returns set of dates already in the database" do
Weather.upsert_solar_index(%{date: ~D[2024-06-15], sfi: 150.2})