Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
+ `if/else` with a classifier that pattern-matches the error reason
directly, returning `:transient | :permanent`, and dispatch via
`case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
step on the row/col comprehensions so an image box whose centre
lands outside the raster collapses to an empty iteration instead
of firing Elixir 1.19's ambiguous-range warning. Adds a regression
test for the right-edge box.
Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
duct_thickness, best_duct_freq, duct_usable_for_band,
distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
handling; parse_snmpget_output OID normalisation and junk-line
tolerance.
- HrrrNativeClient native_level_count, native_variables,
native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
monotonic in RH, finite across the operating envelope) + idempotent
nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
output; latlon_to_pixel round-trip for every grid cell.
Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.
Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
234 lines
7.4 KiB
Elixir
234 lines
7.4 KiB
Elixir
defmodule Microwaveprop.SpaceWeather.ObservationChangesetsTest do
|
|
@moduledoc """
|
|
Cast/validate coverage for the three space-weather observation
|
|
schemas. These were historically at ~50 % line coverage because
|
|
only the happy path of `swpc_client` exercised `changeset/2` — the
|
|
required/optional splits and the unique constraints had no direct
|
|
assertions.
|
|
"""
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Ionosphere.Observation, as: IonoObs
|
|
alias Microwaveprop.SpaceWeather.GeomagneticObservation
|
|
alias Microwaveprop.SpaceWeather.SolarFluxObservation
|
|
alias Microwaveprop.SpaceWeather.SolarXrayObservation
|
|
alias Microwaveprop.Weather.HrrrClimatology
|
|
alias Microwaveprop.Weather.Metar5minObservation
|
|
|
|
describe "GeomagneticObservation.changeset/2" do
|
|
test "valid with only valid_time" do
|
|
cs = GeomagneticObservation.changeset(%GeomagneticObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "accepts kp_index and estimated_kp" do
|
|
cs =
|
|
GeomagneticObservation.changeset(%GeomagneticObservation{}, %{
|
|
valid_time: ~U[2026-04-21 00:00:00Z],
|
|
kp_index: 4,
|
|
estimated_kp: 4.33
|
|
})
|
|
|
|
assert cs.valid?
|
|
assert Ecto.Changeset.get_change(cs, :kp_index) == 4
|
|
assert Ecto.Changeset.get_change(cs, :estimated_kp) == 4.33
|
|
end
|
|
|
|
test "invalid when valid_time is missing" do
|
|
cs = GeomagneticObservation.changeset(%GeomagneticObservation{}, %{kp_index: 3})
|
|
refute cs.valid?
|
|
assert %{valid_time: ["can't be blank"]} = errors_on(cs)
|
|
end
|
|
end
|
|
|
|
describe "SolarFluxObservation.changeset/2" do
|
|
test "valid with required valid_time" do
|
|
cs = SolarFluxObservation.changeset(%SolarFluxObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "accepts frequency, flux, schedule, and 90-day mean" do
|
|
cs =
|
|
SolarFluxObservation.changeset(%SolarFluxObservation{}, %{
|
|
valid_time: ~U[2026-04-21 00:00:00Z],
|
|
frequency_mhz: 2800,
|
|
flux_sfu: 142.4,
|
|
reporting_schedule: "daily",
|
|
ninety_day_mean: 150.0
|
|
})
|
|
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "invalid when valid_time is missing" do
|
|
cs = SolarFluxObservation.changeset(%SolarFluxObservation{}, %{flux_sfu: 142.4})
|
|
refute cs.valid?
|
|
assert %{valid_time: ["can't be blank"]} = errors_on(cs)
|
|
end
|
|
end
|
|
|
|
describe "SolarXrayObservation.changeset/2" do
|
|
test "valid with required valid_time" do
|
|
cs = SolarXrayObservation.changeset(%SolarXrayObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "casts satellite, energy_band, flux, and electron_contaminated" do
|
|
cs =
|
|
SolarXrayObservation.changeset(%SolarXrayObservation{}, %{
|
|
valid_time: ~U[2026-04-21 00:00:00Z],
|
|
satellite: 16,
|
|
flux_wm2: 1.23e-6,
|
|
energy_band: "0.1-0.8nm",
|
|
electron_contaminated: true
|
|
})
|
|
|
|
assert cs.valid?
|
|
assert Ecto.Changeset.get_change(cs, :electron_contaminated) == true
|
|
end
|
|
|
|
test "invalid when valid_time is missing" do
|
|
cs = SolarXrayObservation.changeset(%SolarXrayObservation{}, %{flux_wm2: 1.0e-7})
|
|
refute cs.valid?
|
|
assert %{valid_time: ["can't be blank"]} = errors_on(cs)
|
|
end
|
|
end
|
|
|
|
describe "Ionosphere.Observation.changeset/2" do
|
|
test "valid with required station_code + valid_time" do
|
|
cs =
|
|
IonoObs.changeset(%IonoObs{}, %{
|
|
station_code: "BC840",
|
|
valid_time: ~U[2026-04-21 00:00:00Z]
|
|
})
|
|
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "casts scaled characteristics" do
|
|
cs =
|
|
IonoObs.changeset(%IonoObs{}, %{
|
|
station_code: "BC840",
|
|
valid_time: ~U[2026-04-21 00:00:00Z],
|
|
confidence_score: 75,
|
|
fo_f2_mhz: 10.4,
|
|
fo_e_mhz: 3.2,
|
|
fo_es_mhz: 6.1,
|
|
hm_f2_km: 275.0,
|
|
mufd_mhz: 18.5
|
|
})
|
|
|
|
assert cs.valid?
|
|
assert Ecto.Changeset.get_change(cs, :fo_f2_mhz) == 10.4
|
|
assert Ecto.Changeset.get_change(cs, :confidence_score) == 75
|
|
end
|
|
|
|
test "invalid when either required field is missing" do
|
|
cs1 = IonoObs.changeset(%IonoObs{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
|
|
cs2 = IonoObs.changeset(%IonoObs{}, %{station_code: "BC840"})
|
|
|
|
refute cs1.valid?
|
|
refute cs2.valid?
|
|
assert %{station_code: ["can't be blank"]} = errors_on(cs1)
|
|
assert %{valid_time: ["can't be blank"]} = errors_on(cs2)
|
|
end
|
|
end
|
|
|
|
describe "HrrrClimatology.changeset/2" do
|
|
test "valid with the four required keys" do
|
|
cs =
|
|
HrrrClimatology.changeset(%HrrrClimatology{}, %{
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
month: 7,
|
|
hour: 15
|
|
})
|
|
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "casts mean / stddev / sample_count" do
|
|
cs =
|
|
HrrrClimatology.changeset(%HrrrClimatology{}, %{
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
month: 7,
|
|
hour: 15,
|
|
mean_surface_temp_c: 30.5,
|
|
stddev_surface_temp_c: 4.2,
|
|
sample_count: 365
|
|
})
|
|
|
|
assert cs.valid?
|
|
assert Ecto.Changeset.get_change(cs, :mean_surface_temp_c) == 30.5
|
|
assert Ecto.Changeset.get_change(cs, :sample_count) == 365
|
|
end
|
|
|
|
test "invalid when any of the four cell-identity keys is missing" do
|
|
for missing <- [:lat, :lon, :month, :hour] do
|
|
attrs = Map.delete(%{lat: 32.9, lon: -97.0, month: 7, hour: 15}, missing)
|
|
|
|
cs = HrrrClimatology.changeset(%HrrrClimatology{}, attrs)
|
|
refute cs.valid?, "expected #{missing} missing to invalidate"
|
|
assert errors_on(cs)[missing] == ["can't be blank"]
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "Metar5minObservation.changeset/2" do
|
|
@station_id Ecto.UUID.generate()
|
|
|
|
test "valid with station_id + observed_at" do
|
|
cs =
|
|
Metar5minObservation.changeset(%Metar5minObservation{}, %{
|
|
station_id: @station_id,
|
|
observed_at: ~U[2026-04-21 00:00:00Z]
|
|
})
|
|
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "casts every observation field" do
|
|
cs =
|
|
Metar5minObservation.changeset(%Metar5minObservation{}, %{
|
|
station_id: @station_id,
|
|
observed_at: ~U[2026-04-21 00:05:00Z],
|
|
temp_f: 72.5,
|
|
dewpoint_f: 55.0,
|
|
relative_humidity: 55.0,
|
|
wind_speed_kts: 8.0,
|
|
wind_direction_deg: 180,
|
|
sea_level_pressure_mb: 1013.2,
|
|
altimeter_setting: 29.92,
|
|
sky_condition: "SCT030",
|
|
precip_1h_in: 0.0,
|
|
wx_codes: "VCTS"
|
|
})
|
|
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "invalid without station_id" do
|
|
cs = Metar5minObservation.changeset(%Metar5minObservation{}, %{observed_at: ~U[2026-04-21 00:00:00Z]})
|
|
refute cs.valid?
|
|
assert errors_on(cs)[:station_id] == ["can't be blank"]
|
|
end
|
|
|
|
test "invalid without observed_at" do
|
|
cs = Metar5minObservation.changeset(%Metar5minObservation{}, %{station_id: @station_id})
|
|
refute cs.valid?
|
|
assert errors_on(cs)[:observed_at] == ["can't be blank"]
|
|
end
|
|
end
|
|
|
|
# Local copy of the standard Phoenix.DataCase `errors_on/1` so this
|
|
# file can stay on the lightweight `ExUnit.Case` (no DB sandbox
|
|
# needed for changeset tests).
|
|
defp errors_on(changeset) do
|
|
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
|
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
end)
|
|
end)
|
|
end
|
|
end
|