defmodule Microwaveprop.WeatherExtraTest do @moduledoc """ Fills in coverage holes on lesser-used `Microwaveprop.Weather` context helpers: has_surface_observations?/3, has_sounding?/2, the two `station_ids_with_*` set-membership helpers, `existing_solar_dates/0`, `sounding_times_around/1`, and `hrrr_data_fully_present?/1`. Each is driven with a small, deterministic fixture — we care about the boolean / set return contracts rather than large real-world data shapes. """ use Microwaveprop.DataCase, async: true use ExUnitProperties alias Microwaveprop.Repo alias Microwaveprop.Weather alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.SurfaceObservation defp station!(attrs \\ %{}) do base = %{ station_code: "TST#{System.unique_integer([:positive])}", station_type: "asos", name: "Test Station", lat: 33.0, lon: -97.0 } {:ok, s} = Weather.find_or_create_station(Map.merge(base, attrs)) s end describe "has_surface_observations?/3" do test "true when any observation falls inside the window" do station = station!() {:ok, _} = %SurfaceObservation{} |> SurfaceObservation.changeset(%{ station_id: station.id, observed_at: ~U[2026-04-20 12:00:00Z], temp_f: 72.0 }) |> Repo.insert() assert Weather.has_surface_observations?( station.id, ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z] ) end test "false when the window excludes every observation" do station = station!() {:ok, _} = %SurfaceObservation{} |> SurfaceObservation.changeset(%{ station_id: station.id, observed_at: ~U[2026-04-20 12:00:00Z], temp_f: 72.0 }) |> Repo.insert() refute Weather.has_surface_observations?( station.id, ~U[2026-04-21 00:00:00Z], ~U[2026-04-21 23:59:59Z] ) end test "false for a station with no observations" do station = station!() refute Weather.has_surface_observations?(station.id, ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z]) end end describe "has_sounding?/2" do test "true when a sounding exists at the exact observed_at" do station = station!(%{station_type: "sounding"}) {:ok, _} = %Sounding{} |> Sounding.changeset(%{ station_id: station.id, observed_at: ~U[2026-04-20 12:00:00Z], profile: [], level_count: 0 }) |> Repo.insert() assert Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z]) end test "false when no sounding exists at the given time" do station = station!(%{station_type: "sounding"}) refute Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z]) end end describe "station_ids_with_* set-membership helpers" do test "station_ids_with_surface_observations returns only the stations that have rows in the window" do a = station!() b = station!() c = station!() {:ok, _} = %SurfaceObservation{} |> SurfaceObservation.changeset(%{ station_id: a.id, observed_at: ~U[2026-04-20 12:00:00Z], temp_f: 60.0 }) |> Repo.insert() {:ok, _} = %SurfaceObservation{} |> SurfaceObservation.changeset(%{ station_id: b.id, observed_at: ~U[2026-04-20 13:00:00Z], temp_f: 70.0 }) |> Repo.insert() present = Weather.station_ids_with_surface_observations( [a.id, b.id, c.id], ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z] ) assert MapSet.size(present) == 2 assert MapSet.member?(present, a.id) assert MapSet.member?(present, b.id) refute MapSet.member?(present, c.id) end test "station_ids_with_soundings returns {station_id, observed_at} tuples" do station = station!(%{station_type: "sounding"}) {:ok, _} = %Sounding{} |> Sounding.changeset(%{ station_id: station.id, observed_at: ~U[2026-04-20 12:00:00Z], profile: [], level_count: 0 }) |> Repo.insert() set = Weather.station_ids_with_soundings( [station.id], [~U[2026-04-20 12:00:00Z], ~U[2026-04-20 00:00:00Z]] ) assert MapSet.member?(set, {station.id, ~U[2026-04-20 12:00:00Z]}) refute MapSet.member?(set, {station.id, ~U[2026-04-20 00:00:00Z]}) end end describe "sounding_times_around/1" do # Radiosondes fly twice a day (00Z, 12Z). The helper returns the # two nearest bracketing times for a given wall-clock UTC. test "early-morning times bracket prev-day 12Z and same-day 00Z" do times = Weather.sounding_times_around(~U[2026-04-20 06:00:00Z]) assert length(times) >= 2 assert ~U[2026-04-19 12:00:00Z] in times assert ~U[2026-04-20 00:00:00Z] in times end test "afternoon times bracket same-day 00Z/12Z" do times = Weather.sounding_times_around(~U[2026-04-20 18:00:00Z]) assert ~U[2026-04-20 00:00:00Z] in times assert ~U[2026-04-20 12:00:00Z] in times end test "noon UTC lands on the 12Z bracket" do times = Weather.sounding_times_around(~U[2026-04-20 12:00:00Z]) assert ~U[2026-04-20 12:00:00Z] in times end end describe "hrrr_data_fully_present?/1" do test "false when pos1 is missing" do refute Weather.hrrr_data_fully_present?(%{pos1: nil}) end test "false when qso_timestamp is missing" do refute Weather.hrrr_data_fully_present?(%{qso_timestamp: nil, pos1: %{"lat" => 32.9, "lon" => -97.0}}) end end describe "existing_solar_dates/0" do test "returns a MapSet of Dates for every row in solar_indices" do date = ~D[2026-04-20] Weather.upsert_solar_indices_batch([ %{ date: date, sfi: 120.0, sfi_adjusted: 118.0, sunspot_number: 55, ap_index: 5, kp_values: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] } ]) set = Weather.existing_solar_dates() assert MapSet.member?(set, date) end end describe "iemre_for_contact/1 / iemre_for_path/1 edge cases" do test "iemre_for_contact returns nil with empty DB and valid pos1" do contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: ~U[2026-04-20 18:00:00Z] } assert Weather.iemre_for_contact(contact) == nil end test "iemre_for_path returns [] when pos1 is nil" do assert Weather.iemre_for_path(%{pos1: nil}) == [] end end describe "narr_for_contact/1 / narr_profiles_for_path/1 edge cases" do test "narr_for_contact returns nil when pos1 is nil" do assert Weather.narr_for_contact(%{pos1: nil}) == nil end test "narr_profiles_for_path returns [] when pos1 is nil" do assert Weather.narr_profiles_for_path(%{pos1: nil}) == [] end test "narr_for_contact returns nil when coords present but no profile exists" do contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: ~U[2026-04-20 18:00:00Z] } assert Weather.narr_for_contact(contact) == nil end end describe "best_profile_for_contact/1 fallback behavior" do test "returns nil when neither HRRR nor NARR exists" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, qso_timestamp: ~U[2026-04-20 18:00:00Z] } assert Weather.best_profile_for_contact(contact) == nil end end describe "profiles_along_path/1 fallback" do test "returns [] when no HRRR or NARR is near the path" do contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, pos2: %{"lat" => 41.0, "lon" => -81.0}, qso_timestamp: ~U[2026-04-20 18:00:00Z] } assert Weather.profiles_along_path(contact) == [] end end describe "nearest_native_duct_ghz/3 and nearest_native_duct_info/3" do test "returns nil / {:error, :not_found} with no native profile rows" do ts = ~U[2026-04-20 18:00:00Z] assert Weather.nearest_native_duct_ghz(32.9, -97.0, ts) == nil assert Weather.nearest_native_duct_info(32.9, -97.0, ts) == {:error, :not_found} end end describe "find_nearest_rtma/3" do test "returns nil when no RTMA observation exists nearby" do assert Weather.find_nearest_rtma(40.0, -80.0, ~U[2026-04-20 18:00:00Z]) == nil end end describe "recent_surface_obs/3" do test "returns nil when no nearby stations have data in the time window" do assert Weather.recent_surface_obs(40.0, -80.0, ~U[2026-04-20 18:00:00Z]) == nil end test "returns a surface observation when a nearby station has one in the ±30min window" do station = station!(%{lat: 32.9, lon: -97.04}) {:ok, _} = %SurfaceObservation{} |> SurfaceObservation.changeset(%{ station_id: station.id, observed_at: ~U[2026-04-20 18:05:00Z], temp_f: 78.0, dewpoint_f: 60.0 }) |> Repo.insert() obs = Weather.recent_surface_obs(32.9, -97.04, ~U[2026-04-20 18:00:00Z]) assert obs assert obs.temp_f == 78.0 end end describe "property: round_to_hrrr_grid/2" do property "rounded output stays within 0.005 of input for any CONUS lat/lon" do check all( lat <- float(min: 20.0, max: 55.0), lon <- float(min: -130.0, max: -60.0) ) do {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon) assert abs(rlat - lat) <= 0.005 + 1.0e-9 assert abs(rlon - lon) <= 0.005 + 1.0e-9 # Output is on a 0.01° grid assert_in_delta rlat * 100, round(rlat * 100), 1.0e-6 assert_in_delta rlon * 100, round(rlon * 100), 1.0e-6 end end end describe "property: round_to_iemre_grid/2" do property "rounded output lands on the 0.125° IEMRE grid within ±0.0625° of the input" do check all( lat <- float(min: -60.0, max: 60.0), lon <- float(min: -180.0, max: 180.0) ) do {rlat, rlon} = Weather.round_to_iemre_grid(lat, lon) # Must be on a 0.125° grid (8 per degree). assert_in_delta rlat * 8, round(rlat * 8), 1.0e-6 assert_in_delta rlon * 8, round(rlon * 8), 1.0e-6 # And never farther than half a step from the input. assert abs(rlat - lat) <= 0.0625 + 1.0e-9 assert abs(rlon - lon) <= 0.0625 + 1.0e-9 end end end describe "property: find_or_create_station/1 insert-then-query round-trip" do property "a newly created station is immediately returned by a duplicate call" do check all( suffix <- integer(1..1_000_000), lat <- float(min: 25.0, max: 49.0), lon <- float(min: -124.0, max: -66.0) ) do code = "ROUND#{suffix}" attrs = %{ station_code: code, station_type: "asos", name: "Round-trip station", lat: lat, lon: lon } assert {:ok, first} = Weather.find_or_create_station(attrs) assert {:ok, second} = Weather.find_or_create_station(attrs) assert first.id == second.id assert second.station_code == code end end end end