defmodule Microwaveprop.WeatherTest do use Microwaveprop.DataCase, async: true alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrNativeProfile alias Microwaveprop.Weather.HrrrProfile alias Microwaveprop.Weather.IemreObservation alias Microwaveprop.Weather.SurfaceObservation @station_attrs %{ station_code: "KDFW", station_type: "asos", name: "Dallas/Fort Worth International", lat: 32.8998, lon: -97.0403, elevation_m: 171.0, state: "TX" } @sounding_station_attrs %{ station_code: "FWD", station_type: "sounding", wmo_number: 72_249, name: "Fort Worth", lat: 32.83, lon: -97.30 } describe "build_grid_cache_rows/3 — Rust ProfilesFile shape" do @rust_profile %{ surface_temp_c: 25.0, surface_dewpoint_c: 18.0, surface_pressure_mb: 1013.0, hpbl_m: 1500.0, pwat_mm: 35.0, native_min_gradient: -120.0, profile: [ %{pres_mb: 1013.0, hght_m: 50.0, tmpc: 25.0, dwpc: 18.0}, %{pres_mb: 925.0, hght_m: 800.0, tmpc: 18.0, dwpc: 14.0}, %{pres_mb: 850.0, hght_m: 1500.0, tmpc: 12.0, dwpc: 9.0}, %{pres_mb: 700.0, hght_m: 3000.0, tmpc: 0.0, dwpc: -5.0} ] } test "derives upper-air + refractivity fields from an atom-keyed Rust profile" do grid_data = %{{32.875, -97.0} => @rust_profile} valid_time = ~U[2026-04-24 16:00:00Z] [row] = Weather.build_grid_cache_rows(grid_data, valid_time) # Upper-air fields flow from the normalized profile. assert is_number(row.temp_850mb) assert is_number(row.dewpoint_850mb) assert is_number(row.lapse_rate) assert is_number(row.inversion_strength) # Surface refractivity is derived when Rust doesn't emit it. assert is_number(row.surface_refractivity) # Gradient uses the native_min_gradient fallback when # SoundingParams can't derive one (or matches when it can). assert is_number(row.refractivity_gradient) end end describe "reconcile_weather_statuses/0" do alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact @contact_attrs %{ station1: "W5XD", station2: "K5TR", qso_timestamp: ~U[2023-09-17 17:30:00Z], mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, distance_km: Decimal.new("295") } defp create_contact(attrs \\ %{}) do {:ok, c} = %Contact{} |> Contact.changeset(Map.merge(@contact_attrs, attrs)) |> Repo.insert() c end test "flips queued → complete for contacts with obs in ±2h / 150km window" do {:ok, station} = Weather.find_or_create_station(@station_attrs) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2023-09-17 17:45:00Z], temp_f: 80.0 }) contact = create_contact() Radio.set_enrichment_status!([contact.id], :weather_status, :queued) {:ok, n} = Weather.reconcile_weather_statuses() assert n == 1 assert %{weather_status: :complete} = Repo.get(Contact, contact.id) end test "leaves :queued contacts without nearby obs alone" do {:ok, station} = Weather.find_or_create_station(%{ @station_attrs | station_code: "KSEA", lat: 47.45, lon: -122.31 }) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2023-09-17 17:45:00Z], temp_f: 60.0 }) # Contact is in TX; KSEA obs doesn't cover its pos1/pos2. contact = create_contact() Radio.set_enrichment_status!([contact.id], :weather_status, :queued) {:ok, n} = Weather.reconcile_weather_statuses() assert n == 0 assert %{weather_status: :queued} = Repo.get(Contact, contact.id) end test "scales the longitude band by latitude so high-lat windows still cover ~150 km east/west" do # At lat 49° (US/Canada border) cos(lat) ≈ 0.656, so a fixed 1.5° # longitude box collapses to ~110 km — narrower than the 150 km # window the function documents. A station 1.8° east of the QSO # is roughly 132 km away, well inside the intended radius, but a # latitude-blind bounding box would exclude it. {:ok, station} = Weather.find_or_create_station(%{ @station_attrs | station_code: "KHIGH", lat: 49.0, lon: -121.7 }) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2023-09-17 17:45:00Z], temp_f: 60.0 }) contact = create_contact(%{ station1: "HILAT", station2: "HILAT2", pos1: %{"lat" => 49.0, "lon" => -123.5}, pos2: nil, grid2: nil }) Radio.set_enrichment_status!([contact.id], :weather_status, :queued) {:ok, _n} = Weather.reconcile_weather_statuses() assert %{weather_status: :complete} = Repo.get(Contact, contact.id) end test "leaves :complete and :pending contacts alone" do {:ok, station} = Weather.find_or_create_station(@station_attrs) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2023-09-17 17:45:00Z], temp_f: 80.0 }) c_done = create_contact(%{station1: "DONE"}) Radio.set_enrichment_status!([c_done.id], :weather_status, :complete) c_pending = create_contact(%{station1: "PEND"}) Radio.set_enrichment_status!([c_pending.id], :weather_status, :pending) {:ok, _} = Weather.reconcile_weather_statuses() assert %{weather_status: :complete} = Repo.get(Contact, c_done.id) assert %{weather_status: :pending} = Repo.get(Contact, c_pending.id) end end describe "reconcile_iemre_statuses/0" do alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact @iemre_contact_attrs %{ station1: "W5XD", station2: "K5TR", qso_timestamp: ~U[2023-09-17 17:30:00Z], mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: nil, distance_km: Decimal.new("295") } defp create_iemre_contact(attrs \\ %{}) do {:ok, c} = %Contact{} |> Contact.changeset(Map.merge(@iemre_contact_attrs, attrs)) |> Repo.insert() c end test "flips queued → complete when every path-point has an iemre row for the qso date" do {rlat, rlon} = Weather.round_to_iemre_grid(32.9, -97.0) {:ok, _} = Weather.upsert_iemre_observation(%{lat: rlat, lon: rlon, date: ~D[2023-09-17], hourly: []}) contact = create_iemre_contact() Radio.set_enrichment_status!([contact.id], :iemre_status, :queued) {:ok, n} = Weather.reconcile_iemre_statuses() assert n == 1 assert %{iemre_status: :complete} = Repo.get(Contact, contact.id) end test "leaves :queued contacts whose path points are not all covered" do # pos1 → covered, pos2 → not covered. contact_path_points returns # [pos1, midpoint, pos2] so partial coverage must not flip. pos1 = %{"lat" => 32.9, "lon" => -97.0} pos2 = %{"lat" => 30.3, "lon" => -97.7} {rlat, rlon} = Weather.round_to_iemre_grid(32.9, -97.0) {:ok, _} = Weather.upsert_iemre_observation(%{lat: rlat, lon: rlon, date: ~D[2023-09-17], hourly: []}) contact = create_iemre_contact(%{pos1: pos1, pos2: pos2, distance_km: Decimal.new("295")}) Radio.set_enrichment_status!([contact.id], :iemre_status, :queued) {:ok, n} = Weather.reconcile_iemre_statuses() assert n == 0 assert %{iemre_status: :queued} = Repo.get(Contact, contact.id) end end describe "reconcile_hrrr_statuses/0" do alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact alias Microwaveprop.Weather.HrrrClient test "flips queued → complete when hrrr_data_fully_present?/1 holds" do contact = %Contact{} |> Contact.changeset(%{ station1: "W5XD", station2: "K5TR", qso_timestamp: ~U[2023-09-17 17:30:00Z], mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: nil, distance_km: Decimal.new("295") }) |> Repo.insert!() valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp) {rlat, rlon} = Weather.round_to_hrrr_grid(32.9, -97.0) %HrrrProfile{} |> HrrrProfile.changeset(%{ valid_time: valid_time, lat: rlat, lon: rlon, surface_temp_c: 25.0, surface_dewpoint_c: 15.0, surface_pressure_mb: 1013.0 }) |> Repo.insert!() Radio.set_enrichment_status!([contact.id], :hrrr_status, :queued) {:ok, n} = Weather.reconcile_hrrr_statuses() assert n == 1 assert %{hrrr_status: :complete} = Repo.get(Contact, contact.id) end end describe "find_or_create_station/1" do test "creates a new station" do assert {:ok, station} = Weather.find_or_create_station(@station_attrs) assert station.station_code == "KDFW" assert station.station_type == "asos" end test "returns existing station if already created" do {:ok, first} = Weather.find_or_create_station(@station_attrs) {:ok, second} = Weather.find_or_create_station(@station_attrs) assert first.id == second.id end test "returns error for invalid attributes" do assert {:error, _changeset} = Weather.find_or_create_station(%{station_code: "X"}) end end describe "upsert_surface_observation/2" do test "inserts a new observation" do {:ok, station} = Weather.find_or_create_station(@station_attrs) obs_attrs = %{ observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 75.0, dewpoint_f: 55.0, sky_condition: "SCT" } assert {:ok, obs} = Weather.upsert_surface_observation(station, obs_attrs) assert obs.temp_f == 75.0 assert obs.station_id == station.id end test "updates existing observation on conflict" do {:ok, station} = Weather.find_or_create_station(@station_attrs) obs_attrs = %{ observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 75.0, sky_condition: "SCT" } {:ok, first} = Weather.upsert_surface_observation(station, obs_attrs) {:ok, second} = Weather.upsert_surface_observation(station, %{obs_attrs | temp_f: 78.0}) assert first.id == second.id assert second.temp_f == 78.0 end end describe "upsert_surface_observations/2" do test "bulk inserts all rows in a single call" do {:ok, station} = Weather.find_or_create_station(@station_attrs) rows = for i <- 0..99 do %{ observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second), temp_f: 40.0 + i / 10, dewpoint_f: 30.0 + i / 10, sky_condition: "CLR" } end assert {count, _} = Weather.upsert_surface_observations(station, rows) assert count == 100 assert Repo.aggregate(SurfaceObservation, :count) == 100 end test "overlapping observed_at updates instead of duplicating" do {:ok, station} = Weather.find_or_create_station(@station_attrs) rows1 = for i <- 0..9 do %{ observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second), temp_f: 40.0, dewpoint_f: 30.0 } end {10, _} = Weather.upsert_surface_observations(station, rows1) rows2 = for i <- 0..9 do %{ observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second), temp_f: 80.0, dewpoint_f: 60.0 } end {_, _} = Weather.upsert_surface_observations(station, rows2) assert Repo.aggregate(SurfaceObservation, :count) == 10 obs = Repo.all(SurfaceObservation) assert Enum.all?(obs, &(&1.temp_f == 80.0)) end test "returns {0, _} for empty list without hitting the DB" do {:ok, station} = Weather.find_or_create_station(@station_attrs) assert {0, _} = Weather.upsert_surface_observations(station, []) end test "skips rows without observed_at" do {:ok, station} = Weather.find_or_create_station(@station_attrs) rows = [ %{observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 70.0}, %{observed_at: nil, temp_f: 99.0}, %{temp_f: 99.0} ] assert {1, _} = Weather.upsert_surface_observations(station, rows) assert Repo.aggregate(SurfaceObservation, :count) == 1 end test "dedupes rows sharing (station_id, observed_at), keeping the last" do {:ok, station} = Weather.find_or_create_station(@station_attrs) ts = ~U[2024-09-22 20:00:00Z] # IEM occasionally returns two METAR records with the same # timestamp. Passing both to a single insert_all triggers # Postgres 21000 cardinality_violation because ON CONFLICT # cannot update the same row twice in one command. rows = [ %{observed_at: ts, temp_f: 70.0, dewpoint_f: 60.0}, %{observed_at: ts, temp_f: 72.0, dewpoint_f: 61.0} ] assert {1, _} = Weather.upsert_surface_observations(station, rows) assert Repo.aggregate(SurfaceObservation, :count) == 1 [obs] = Repo.all(SurfaceObservation) assert obs.temp_f == 72.0 assert obs.dewpoint_f == 61.0 end end describe "upsert_sounding/2" do @sample_profile [ %{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10}, %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 15}, %{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0, "drct" => 220, "sknt" => 20} ] test "inserts a new sounding" do {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) sounding_attrs = %{ observed_at: ~U[2026-03-28 12:00:00Z], profile: @sample_profile, level_count: 3, surface_temp_c: 25.0 } assert {:ok, sounding} = Weather.upsert_sounding(station, sounding_attrs) assert sounding.level_count == 3 assert sounding.station_id == station.id end test "updates existing sounding on conflict" do {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) sounding_attrs = %{ observed_at: ~U[2026-03-28 12:00:00Z], profile: @sample_profile, level_count: 3, surface_temp_c: 25.0 } {:ok, first} = Weather.upsert_sounding(station, sounding_attrs) {:ok, second} = Weather.upsert_sounding(station, %{sounding_attrs | surface_temp_c: 26.0}) assert first.id == second.id assert second.surface_temp_c == 26.0 end end describe "weather_for_contact/2" do test "returns nearby surface observations within time window with station preloaded" do {:ok, station} = Weather.find_or_create_station(@station_attrs) {:ok, _obs} = Weather.upsert_surface_observation(station, %{ observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 75.0 }) # QSO near the station in space and time result = Weather.weather_for_contact( %{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 18:30:00Z]}, radius_km: 100, time_window_hours: 3 ) assert length(result.surface_observations) == 1 obs = hd(result.surface_observations) assert obs.temp_f == 75.0 assert obs.station.station_code == "KDFW" end test "returns nearby soundings within time window with station preloaded" 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: [%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10}], level_count: 1, surface_temp_c: 25.0 }) result = Weather.weather_for_contact( %{lat: 32.85, lon: -97.28, timestamp: ~U[2026-03-28 14:00:00Z]}, radius_km: 100, time_window_hours: 6 ) assert length(result.soundings) == 1 assert hd(result.soundings).station.station_code == "FWD" end test "excludes observations outside radius" do {:ok, station} = Weather.find_or_create_station(@station_attrs) {:ok, _obs} = Weather.upsert_surface_observation(station, %{ observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 75.0 }) # QSO far away result = Weather.weather_for_contact( %{lat: 40.0, lon: -80.0, timestamp: ~U[2026-03-28 18:00:00Z]}, radius_km: 100, time_window_hours: 3 ) assert result.surface_observations == [] end test "excludes observations outside time window" do {:ok, station} = Weather.find_or_create_station(@station_attrs) {:ok, _obs} = Weather.upsert_surface_observation(station, %{ observed_at: ~U[2026-03-28 06:00:00Z], temp_f: 75.0 }) # QSO much later result = Weather.weather_for_contact( %{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 22:00:00Z]}, radius_km: 100, time_window_hours: 3 ) assert result.surface_observations == [] end end describe "soundings_with_widening_radius/1" do test "returns soundings at the first radius that finds any" do # Station ~220 km east of the query point: outside the 150 km ring # but inside the 300 km ring. {:ok, far_station} = Weather.find_or_create_station(%{ station_code: "FAR", station_type: "sounding", wmo_number: 72_001, name: "Far Site", lat: 32.9, lon: -94.6 }) Weather.upsert_sounding(far_station, %{ observed_at: ~U[2026-03-28 12:00:00Z], profile: [%{"pres" => 1013, "hght" => 100, "tmpc" => 20, "dwpc" => 10}], level_count: 1 }) result = Weather.soundings_with_widening_radius(%{ lat: 32.9, lon: -97.0, timestamp: ~U[2026-03-28 14:00:00Z] }) assert result.soundings != [] # 130 km is outside 150 but inside 300 assert result.radius_km == 300 refute result.exhausted end test "reports exhausted when no soundings exist within the widest radius" do result = Weather.soundings_with_widening_radius(%{ lat: 32.9, lon: -97.0, timestamp: ~U[2026-03-28 14:00:00Z] }) assert result.soundings == [] assert result.exhausted assert result.radius_km == 1000 end end describe "has_surface_observations?/3" do test "returns true when observations exist in the time window" do {:ok, station} = Weather.find_or_create_station(@station_attrs) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 75.0 }) assert Weather.has_surface_observations?( station.id, ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z] ) end test "returns false when no observations exist in the time window" do {:ok, station} = Weather.find_or_create_station(@station_attrs) refute Weather.has_surface_observations?( station.id, ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z] ) end test "returns false when observations are outside the time window" do {:ok, station} = Weather.find_or_create_station(@station_attrs) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2026-03-28 06:00:00Z], temp_f: 60.0 }) refute Weather.has_surface_observations?( station.id, ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z] ) end end describe "has_sounding?/2" do test "returns true when a sounding exists at the given time" do {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) Weather.upsert_sounding(station, %{ observed_at: ~U[2026-03-28 12:00:00Z], profile: [%{"pres" => 1013.0, "tmpc" => 25.0}], level_count: 1 }) assert Weather.has_sounding?(station.id, ~U[2026-03-28 12:00:00Z]) end test "returns false when no sounding exists at the given time" do {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) refute Weather.has_sounding?(station.id, ~U[2026-03-28 12:00:00Z]) 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}) Weather.upsert_solar_index(%{date: ~D[2024-06-16], sfi: 145.0}) dates = Weather.existing_solar_dates() assert MapSet.member?(dates, ~D[2024-06-15]) assert MapSet.member?(dates, ~D[2024-06-16]) refute MapSet.member?(dates, ~D[2024-06-17]) end test "returns empty set when no solar indices exist" do assert Weather.existing_solar_dates() == MapSet.new() end end describe "nearby_stations/4" do test "returns stations within radius" do {:ok, _near} = Weather.find_or_create_station(%{ station_code: "KDFW", station_type: "asos", name: "DFW Airport", lat: 32.90, lon: -97.04 }) {:ok, _far} = Weather.find_or_create_station(%{ station_code: "KORD", station_type: "asos", name: "Chicago O'Hare", lat: 41.98, lon: -87.90 }) stations = Weather.nearby_stations(32.85, -97.05, "asos", 50) codes = Enum.map(stations, & &1.station_code) assert "KDFW" in codes refute "KORD" in codes end test "filters by station type" do {:ok, _asos} = Weather.find_or_create_station(%{ station_code: "KDFW", station_type: "asos", name: "DFW Airport", lat: 32.90, lon: -97.04 }) {:ok, _sounding} = Weather.find_or_create_station(%{ station_code: "FWD", station_type: "sounding", name: "Fort Worth", lat: 32.83, lon: -97.30 }) stations = Weather.nearby_stations(32.85, -97.10, "sounding", 100) codes = Enum.map(stations, & &1.station_code) assert "FWD" in codes refute "KDFW" in codes end test "returns empty list when no stations in radius" do {:ok, _} = Weather.find_or_create_station(%{ station_code: "KDFW", station_type: "asos", name: "DFW Airport", lat: 32.90, lon: -97.04 }) assert Weather.nearby_stations(45.0, -80.0, "asos", 50) == [] end end describe "sounding_times_around/1" do test "returns bracketing 00Z and 12Z for afternoon UTC" do dt = ~U[2026-03-28 18:00:00Z] times = Weather.sounding_times_around(dt) assert times == [~U[2026-03-28 00:00:00Z], ~U[2026-03-28 12:00:00Z]] end test "returns bracketing times for morning UTC" do dt = ~U[2026-03-28 06:00:00Z] times = Weather.sounding_times_around(dt) assert times == [~U[2026-03-27 12:00:00Z], ~U[2026-03-28 00:00:00Z]] end test "returns single time at exactly 00Z" do dt = ~U[2026-03-28 00:00:00Z] times = Weather.sounding_times_around(dt) assert times == [~U[2026-03-27 12:00:00Z], ~U[2026-03-28 00:00:00Z]] end test "returns single time at exactly 12Z" do dt = ~U[2026-03-28 12:00:00Z] times = Weather.sounding_times_around(dt) assert times == [~U[2026-03-28 00:00:00Z], ~U[2026-03-28 12:00:00Z]] end end describe "get_solar_index/1" do test "returns the solar index record for a given date" do {:ok, record} = Weather.upsert_solar_index(%{date: ~D[2024-06-15], sfi: 150.2, sunspot_number: 120}) found = Weather.get_solar_index(~D[2024-06-15]) assert found.id == record.id assert found.sfi == 150.2 end test "returns nil when no record exists" do assert Weather.get_solar_index(~D[2024-01-01]) == nil end end describe "upsert_solar_index/1" do test "inserts a new solar index record" do attrs = %{ date: ~D[2024-06-15], sfi: 150.2, sfi_adjusted: 148.5, sunspot_number: 120, ap_index: 8, kp_values: [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667] } assert {:ok, record} = Weather.upsert_solar_index(attrs) assert record.date == ~D[2024-06-15] assert record.sfi == 150.2 assert record.sunspot_number == 120 assert record.ap_index == 8 assert length(record.kp_values) == 8 end test "updates existing record on date conflict" do attrs = %{ date: ~D[2024-06-15], sfi: 150.2, sfi_adjusted: 148.5, sunspot_number: 120, ap_index: 8, kp_values: [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667] } {:ok, first} = Weather.upsert_solar_index(attrs) {:ok, second} = Weather.upsert_solar_index(%{attrs | sfi: 155.0}) assert first.id == second.id assert second.sfi == 155.0 end test "inserts record with nil optional fields" do attrs = %{date: ~D[1932-01-01], sfi: nil, sfi_adjusted: nil, sunspot_number: 22, ap_index: 15} assert {:ok, record} = Weather.upsert_solar_index(attrs) assert record.date == ~D[1932-01-01] assert is_nil(record.sfi) assert record.sunspot_number == 22 end end @hrrr_profile_attrs %{ valid_time: ~U[2026-03-28 18:00:00Z], lat: 32.90, lon: -97.04, run_time: ~U[2026-03-28 18:00:00Z], profile: [ %{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}, %{"pres" => 975.0, "hght" => 350, "tmpc" => 23.0, "dwpc" => 16.0} ], hpbl_m: 1500.0, pwat_mm: 25.0, surface_temp_c: 25.0, surface_dewpoint_c: 18.0, surface_pressure_mb: 1013.0, surface_refractivity: 320.5, min_refractivity_gradient: -45.0, ducting_detected: false } describe "upsert_hrrr_profile/1" do test "inserts a new HRRR profile" do assert {:ok, profile} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs) assert profile.lat == 32.90 assert profile.hpbl_m == 1500.0 end test "preserves existing profile on conflict" do {:ok, first} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs) Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | hpbl_m: 2000.0}) reloaded = Repo.get!(HrrrProfile, first.id) assert reloaded.hpbl_m == 1500.0 assert Repo.aggregate(HrrrProfile, :count) == 1 end end describe "purge_grid_point_profiles/0" do test "deletes all grid-point rows across partitions while preserving QSO-linked rows" do # QSO-linked row (is_grid_point defaults to false) {:ok, qso_linked} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs) # Grid-point row in a historical partition Repo.query!( """ INSERT INTO hrrr_profiles (id, valid_time, lat, lon, is_grid_point, inserted_at, updated_at) VALUES ($1, $2, $3, $4, true, $5, $5) """, [ Ecto.UUID.bingenerate(), ~N[2023-07-15 12:00:00], 32.125, -97.125, NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) ] ) # Grid-point row in the current partition Repo.query!( """ INSERT INTO hrrr_profiles (id, valid_time, lat, lon, is_grid_point, inserted_at, updated_at) VALUES ($1, $2, $3, $4, true, $5, $5) """, [ Ecto.UUID.bingenerate(), NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), 33.125, -96.125, NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) ] ) assert Repo.aggregate(HrrrProfile, :count) == 3 assert Weather.purge_grid_point_profiles() == 2 remaining = Repo.all(HrrrProfile) assert length(remaining) == 1 assert hd(remaining).id == qso_linked.id end test "returns 0 when there are no grid-point rows" do {:ok, _} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs) assert Weather.purge_grid_point_profiles() == 0 assert Repo.aggregate(HrrrProfile, :count) == 1 end end describe "has_hrrr_profile?/3" do test "returns true when a profile exists at the given location and time" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) assert Weather.has_hrrr_profile?(32.90, -97.04, ~U[2026-03-28 18:00:00Z]) end test "returns false when no profile exists" do refute Weather.has_hrrr_profile?(32.90, -97.04, ~U[2026-03-28 18:00:00Z]) end test "returns false for different location" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) refute Weather.has_hrrr_profile?(40.0, -80.0, ~U[2026-03-28 18:00:00Z]) end end describe "hrrr_for_contact/1" do test "returns nearest HRRR profile for a QSO" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) contact = %{ pos1: %{"lat" => 32.91, "lon" => -97.05}, qso_timestamp: ~U[2026-03-28 18:30:00Z] } profile = Weather.hrrr_for_contact(contact) assert profile assert profile.lat == 32.90 end test "returns nil when no HRRR profiles exist nearby" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } assert Weather.hrrr_for_contact(contact) == nil end test "returns nil when QSO has no pos1" do assert Weather.hrrr_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil end end describe "hrrr_data_fully_present?/1" do # nearest_hrrr_hour rounds <:30 down; pick 18:15 so it maps to 18:00 to # match the valid_time in @hrrr_profile_attrs. @contact_time ~U[2026-03-28 18:15:00Z] test "returns true when a pos1-only contact's snapped point has a profile" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) contact = %{ pos1: %{"lat" => 32.9047, "lon" => -97.0382}, pos2: nil, qso_timestamp: @contact_time } assert Weather.hrrr_data_fully_present?(contact) end test "returns false when a pos1-only contact's snapped point has no profile" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, pos2: nil, qso_timestamp: @contact_time } refute Weather.hrrr_data_fully_present?(contact) end test "returns true only when every path point (endpoint + midpoint + endpoint) has a profile" do # Contact from (32.9, -97.04) to (33.1, -96.96) → midpoint ≈ (33.0, -97.0) for {lat, lon} <- [{32.9, -97.04}, {33.0, -97.0}, {33.1, -96.96}] do Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | lat: lat, lon: lon}) end contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.04}, pos2: %{"lat" => 33.1, "lon" => -96.96}, qso_timestamp: @contact_time } assert Weather.hrrr_data_fully_present?(contact) end test "returns false when the midpoint profile is missing" do # Only endpoints present — midpoint (33.0, -97.0) missing for {lat, lon} <- [{32.9, -97.04}, {33.1, -96.96}] do Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | lat: lat, lon: lon}) end contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.04}, pos2: %{"lat" => 33.1, "lon" => -96.96}, qso_timestamp: @contact_time } refute Weather.hrrr_data_fully_present?(contact) end test "returns false when pos1 is nil" do refute Weather.hrrr_data_fully_present?(%{ pos1: nil, pos2: nil, qso_timestamp: @contact_time }) end test "returns false when qso_timestamp is nil" do # A Contact with no timestamp can't be mapped to an HRRR hour; guard # against HrrrClient.nearest_hrrr_hour(nil) crashing here. refute Weather.hrrr_data_fully_present?(%{ pos1: %{"lat" => 32.9, "lon" => -97.04}, pos2: nil, qso_timestamp: nil }) end end describe "round_to_hrrr_grid/2" do test "rounds coordinates to 2 decimal places" do assert Weather.round_to_hrrr_grid(32.9047, -97.0382) == {32.90, -97.04} end test "handles negative values correctly" do assert Weather.round_to_hrrr_grid(-33.456, -97.999) == {-33.46, -98.0} end end describe "round_to_iemre_grid/2" do test "rounds to nearest 0.125 degree" do assert Weather.round_to_iemre_grid(32.9047, -97.0382) == {32.875, -97.0} end test "handles exact grid points" do assert Weather.round_to_iemre_grid(33.0, -97.125) == {33.0, -97.125} end test "handles negative values correctly" do assert Weather.round_to_iemre_grid(-33.44, -97.99) == {-33.5, -98.0} end end @iemre_attrs %{ lat: 32.875, lon: -97.0, date: ~D[2026-03-28], hourly: [%{"hour" => 0, "p01m_mm" => 0.0}] } describe "upsert_iemre_observation/1" do test "inserts a new IEMRE observation" do assert {:ok, obs} = Weather.upsert_iemre_observation(@iemre_attrs) assert obs.lat == 32.875 assert obs.date == ~D[2026-03-28] end test "preserves existing observation on conflict" do {:ok, first} = Weather.upsert_iemre_observation(@iemre_attrs) Weather.upsert_iemre_observation(%{ @iemre_attrs | hourly: [%{"hour" => 0, "p01m_mm" => 1.5}] }) reloaded = Repo.get!(IemreObservation, first.id) assert hd(reloaded.hourly)["p01m_mm"] == 0.0 assert Repo.aggregate(IemreObservation, :count) == 1 end end describe "has_iemre_observation?/3" do test "returns true when observation exists" do Weather.upsert_iemre_observation(@iemre_attrs) assert Weather.has_iemre_observation?(32.875, -97.0, ~D[2026-03-28]) end test "returns false when no observation exists" do refute Weather.has_iemre_observation?(32.875, -97.0, ~D[2026-03-28]) end test "returns false for different location" do Weather.upsert_iemre_observation(@iemre_attrs) refute Weather.has_iemre_observation?(40.0, -80.0, ~D[2026-03-28]) end end describe "hrrr_profiles_for_path/1" do test "returns HRRR profiles for all path points" do # Profile at pos1 Weather.upsert_hrrr_profile(@hrrr_profile_attrs) # Profile near pos2 Weather.upsert_hrrr_profile(%{ @hrrr_profile_attrs | lat: 30.30, lon: -97.70 }) contact = %{ pos1: %{"lat" => 32.91, "lon" => -97.05}, pos2: %{"lat" => 30.31, "lon" => -97.71}, qso_timestamp: ~U[2026-03-28 18:30:00Z] } profiles = Weather.hrrr_profiles_for_path(contact) assert length(profiles) >= 2 end test "returns empty list when no HRRR profiles exist nearby" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, pos2: %{"lat" => 41.0, "lon" => -81.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } assert Weather.hrrr_profiles_for_path(contact) == [] end test "returns profiles for pos1 only when pos2 is nil" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) contact = %{ pos1: %{"lat" => 32.91, "lon" => -97.05}, pos2: nil, qso_timestamp: ~U[2026-03-28 18:30:00Z] } profiles = Weather.hrrr_profiles_for_path(contact) assert length(profiles) == 1 end end describe "iemre_for_path/1" do test "returns IEMRE observations for all path points" do Weather.upsert_iemre_observation(@iemre_attrs) # Observation near pos2 Weather.upsert_iemre_observation(%{ lat: 30.25, lon: -97.75, date: ~D[2026-03-28], hourly: [%{"hour" => 0, "p01m_mm" => 1.0}] }) contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } observations = Weather.iemre_for_path(contact) assert length(observations) >= 2 end test "returns empty list when no observations exist" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, pos2: %{"lat" => 41.0, "lon" => -81.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } assert Weather.iemre_for_path(contact) == [] end test "returns observation for pos1 only when pos2 is nil" do Weather.upsert_iemre_observation(@iemre_attrs) contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z] } observations = Weather.iemre_for_path(contact) assert length(observations) == 1 end end describe "find_nearest_hrrr/3" do test "returns nearest HRRR profile for given lat, lon, and timestamp" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) profile = Weather.find_nearest_hrrr(32.91, -97.05, ~U[2026-03-28 18:30:00Z]) assert profile assert profile.lat == 32.90 end test "returns nil when no HRRR profiles exist nearby" do assert Weather.find_nearest_hrrr(40.0, -80.0, ~U[2026-03-28 18:00:00Z]) == nil end end describe "find_nearest_native_profile/3" do @native_attrs %{ valid_time: ~U[2026-03-28 18:00:00Z], run_time: ~U[2026-03-28 18:00:00Z], lat: 32.90, lon: -97.04, level_count: 2, heights_m: [10.0, 500.0], temp_k: [295.0, 285.0], spfh: [0.010, 0.004], pressure_pa: [101_000.0, 95_000.0], u_wind_ms: [2.0, 3.0], v_wind_ms: [1.0, 1.5], tke_m2s2: [0.5, 0.1] } test "returns nearest native profile within bounding box + time window" do {:ok, _} = %HrrrNativeProfile{} |> HrrrNativeProfile.changeset(@native_attrs) |> Microwaveprop.Repo.insert() profile = Weather.find_nearest_native_profile(32.91, -97.05, ~U[2026-03-28 18:20:00Z]) assert profile assert profile.lat == 32.90 assert profile.level_count == 2 end test "returns nil when no native profile exists nearby" do assert Weather.find_nearest_native_profile(40.0, -80.0, ~U[2026-03-28 18:00:00Z]) == nil end test "returns nil when the nearest profile is outside the time window" do {:ok, _} = %HrrrNativeProfile{} |> HrrrNativeProfile.changeset(@native_attrs) |> Microwaveprop.Repo.insert() # 3+ hours away from the stored 18:00Z profile. assert Weather.find_nearest_native_profile(32.91, -97.05, ~U[2026-03-28 22:00:00Z]) == nil end end describe "iemre_for_contact/1" do test "returns IEMRE observation matching QSO pos1 and date" do Weather.upsert_iemre_observation(@iemre_attrs) contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } obs = Weather.iemre_for_contact(contact) assert obs assert obs.lat == 32.875 end test "returns nil when no IEMRE observation exists" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } assert Weather.iemre_for_contact(contact) == nil end test "returns nil when QSO has no pos1" do assert Weather.iemre_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil end end end