diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 47748470..70c4e3d6 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -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 diff --git a/test/microwaveprop/weather_test.exs b/test/microwaveprop/weather_test.exs index b2e90d65..ff287d72 100644 --- a/test/microwaveprop/weather_test.exs +++ b/test/microwaveprop/weather_test.exs @@ -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})