test: expand coverage and refactor to idiomatic style
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.
This commit is contained in:
parent
8c0865482c
commit
764643bc62
15 changed files with 767 additions and 45 deletions
|
|
@ -404,8 +404,8 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
|||
%{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols} = payload
|
||||
{row_lo, row_hi, col_lo, col_hi} = bounds_to_index_range(payload, bounds)
|
||||
|
||||
for row <- row_lo..row_hi,
|
||||
col <- col_lo..col_hi,
|
||||
for row <- row_lo..row_hi//1,
|
||||
col <- col_lo..col_hi//1,
|
||||
byte = :binary.at(body, row * n_cols + col),
|
||||
byte != @no_data do
|
||||
# guard against the ignored variables warning
|
||||
|
|
|
|||
|
|
@ -224,10 +224,12 @@ defmodule Microwaveprop.Weather.NexradClient do
|
|||
y_min = max(cy - half, 0)
|
||||
y_max = min(cy + half, height - 1)
|
||||
|
||||
# Collect non-zero dBZ values
|
||||
# Collect non-zero dBZ values. `//1` forces an ascending range so
|
||||
# a box whose center is outside the image (min > max after
|
||||
# clamping) becomes empty rather than iterating backwards.
|
||||
dbz_values =
|
||||
for y <- y_min..y_max,
|
||||
x <- x_min..x_max,
|
||||
for y <- y_min..y_max//1,
|
||||
x <- x_min..x_max//1,
|
||||
offset = y * width + x,
|
||||
offset >= 0,
|
||||
offset < byte_size(pixels),
|
||||
|
|
|
|||
|
|
@ -55,30 +55,37 @@ defmodule Microwaveprop.Workers.IemreFetchWorker do
|
|||
end
|
||||
|
||||
defp handle_error(reason, lat, lon, date, date_str) do
|
||||
if transient_failure?(reason) do
|
||||
Logger.error("IEMRE transient error for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
else
|
||||
# Permanent upstream failure (e.g. 404, 422 out-of-grid). Stub the
|
||||
# bucket so future backfill enqueues skip the fetch and let
|
||||
# ContactWeatherEnqueueWorker reconcile the contacts' :queued →
|
||||
# :complete transition via the empty-jobs branch.
|
||||
_ = Weather.upsert_iemre_observation(%{lat: lat, lon: lon, date: date, hourly: []})
|
||||
case classify_failure(reason) do
|
||||
:transient ->
|
||||
Logger.error("IEMRE transient error for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
|
||||
Logger.warning("IEMRE permanent failure for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}, stored stub")
|
||||
:permanent ->
|
||||
# Permanent upstream failure (e.g. 404, 422 out-of-grid). Stub
|
||||
# the bucket so future backfill enqueues skip the fetch and
|
||||
# let ContactWeatherEnqueueWorker reconcile the contacts'
|
||||
# :queued → :complete transition via the empty-jobs branch.
|
||||
_ = Weather.upsert_iemre_observation(%{lat: lat, lon: lon, date: date, hourly: []})
|
||||
|
||||
{:cancel, reason}
|
||||
Logger.warning("IEMRE permanent failure for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}, stored stub")
|
||||
|
||||
{:cancel, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp transient_failure?(%{__exception__: true}), do: true
|
||||
defp transient_failure?("IEM IEMRE HTTP " <> status), do: server_error?(status)
|
||||
defp transient_failure?(_), do: false
|
||||
# Network/transport exceptions are always transient; retry.
|
||||
defp classify_failure(%{__exception__: true}), do: :transient
|
||||
|
||||
defp server_error?(status) do
|
||||
# IEM surfaces the HTTP status in the reason string. Split the integer
|
||||
# back out and pattern-match rate-limit / 5xx codes as transient.
|
||||
defp classify_failure("IEM IEMRE HTTP " <> status) do
|
||||
case Integer.parse(status) do
|
||||
{code, _} when code in [429, 500, 502, 503, 504] -> true
|
||||
_ -> false
|
||||
{code, _} when code in [429, 500, 502, 503, 504] -> :transient
|
||||
_ -> :permanent
|
||||
end
|
||||
end
|
||||
|
||||
# Anything else (404, 422, unexpected shapes) is permanent — cancel
|
||||
# the job and stub the bucket.
|
||||
defp classify_failure(_), do: :permanent
|
||||
end
|
||||
|
|
|
|||
|
|
@ -63,25 +63,11 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
|
|||
|
||||
describe "native_surface_refractivity/3" do
|
||||
test "computes N-units from native profile surface scalars" do
|
||||
{:ok, _} =
|
||||
%HrrrNativeProfile{}
|
||||
|> HrrrNativeProfile.changeset(%{
|
||||
valid_time: @valid_time,
|
||||
lat: @point_lat,
|
||||
lon: @point_lon,
|
||||
level_count: 1,
|
||||
heights_m: [10.0],
|
||||
temp_k: [295.0],
|
||||
spfh: [0.010],
|
||||
pressure_pa: [101_325.0],
|
||||
u_wind_ms: [2.0],
|
||||
v_wind_ms: [1.0],
|
||||
tke_m2s2: [0.5],
|
||||
surface_temp_k: 295.0,
|
||||
surface_spfh: 0.010,
|
||||
surface_pressure_pa: 101_325.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
insert_native_profile(%{
|
||||
surface_temp_k: 295.0,
|
||||
surface_spfh: 0.010,
|
||||
surface_pressure_pa: 101_325.0
|
||||
})
|
||||
|
||||
result = Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time)
|
||||
assert is_float(result)
|
||||
|
|
@ -93,4 +79,133 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
|
|||
assert Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "theta_e_jump/3" do
|
||||
test "returns the native profile's theta_e_jump_k" do
|
||||
insert_native_profile(%{theta_e_jump_k: 4.75})
|
||||
assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == 4.75
|
||||
end
|
||||
|
||||
test "returns nil when no native profile or no jump value" do
|
||||
assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == nil
|
||||
insert_native_profile(%{theta_e_jump_k: nil})
|
||||
assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "shear_at_top/3" do
|
||||
test "returns the native profile's shear_at_top_ms" do
|
||||
insert_native_profile(%{shear_at_top_ms: 3.2})
|
||||
assert Features.shear_at_top(@point_lat, @point_lon, @valid_time) == 3.2
|
||||
end
|
||||
|
||||
test "returns nil when the native profile lacks a shear value" do
|
||||
insert_native_profile(%{shear_at_top_ms: nil})
|
||||
assert Features.shear_at_top(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "duct_thickness/3" do
|
||||
test "returns the thickest duct's thickness_m" do
|
||||
insert_native_profile(%{
|
||||
ducts: [
|
||||
%{"thickness_m" => 120.0, "min_freq_ghz" => 18.0},
|
||||
%{"thickness_m" => 340.0, "min_freq_ghz" => 9.5},
|
||||
%{"thickness_m" => 210.0, "min_freq_ghz" => 12.0}
|
||||
]
|
||||
})
|
||||
|
||||
assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == 340.0
|
||||
end
|
||||
|
||||
test "returns nil when there are no ducts" do
|
||||
insert_native_profile(%{ducts: []})
|
||||
assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
|
||||
test "returns nil when no native profile exists" do
|
||||
assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "best_duct_freq/3" do
|
||||
test "returns the native profile's best_duct_band_ghz" do
|
||||
insert_native_profile(%{best_duct_band_ghz: 12.5})
|
||||
assert Features.best_duct_freq(@point_lat, @point_lon, @valid_time) == 12.5
|
||||
end
|
||||
|
||||
test "returns nil when the native profile lacks best_duct_band_ghz" do
|
||||
insert_native_profile(%{best_duct_band_ghz: nil})
|
||||
assert Features.best_duct_freq(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "duct_usable_for_band/4" do
|
||||
test "returns 1.0 when the best duct traps the requested band" do
|
||||
insert_native_profile(%{best_duct_band_ghz: 10.0})
|
||||
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 24.0) == 1.0
|
||||
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == 1.0
|
||||
end
|
||||
|
||||
test "returns 0.0 when the best duct can't trap the requested band" do
|
||||
insert_native_profile(%{best_duct_band_ghz: 20.0})
|
||||
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == 0.0
|
||||
end
|
||||
|
||||
test "returns nil when there's no native profile" do
|
||||
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "distance_to_front/3 and parallel_to_front/3 placeholders" do
|
||||
test "both return nil and never touch the database" do
|
||||
assert Features.distance_to_front(@point_lat, @point_lon, @valid_time) == nil
|
||||
assert Features.parallel_to_front(@point_lat, @point_lon, @valid_time) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_features/0" do
|
||||
test "returns a name-keyed map of 3-arity feature closures" do
|
||||
features = Features.all_features()
|
||||
assert is_map(features)
|
||||
assert Map.has_key?(features, "naive_gradient")
|
||||
assert Map.has_key?(features, "td_depression")
|
||||
assert Map.has_key?(features, "time_of_day")
|
||||
assert Map.has_key?(features, "duct_thickness")
|
||||
|
||||
# Excluded per the @doc contract
|
||||
refute Map.has_key?(features, "duct_usable_for_band")
|
||||
refute Map.has_key?(features, "distance_to_front")
|
||||
refute Map.has_key?(features, "bulk_richardson")
|
||||
|
||||
# Every value is a 3-arity closure that returns a float or nil
|
||||
# without raising.
|
||||
for {_name, fun} <- features do
|
||||
assert is_function(fun, 3)
|
||||
result = fun.(@point_lat, @point_lon, @valid_time)
|
||||
assert is_nil(result) or is_float(result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_native_profile(attrs) do
|
||||
base = %{
|
||||
valid_time: @valid_time,
|
||||
lat: @point_lat,
|
||||
lon: @point_lon,
|
||||
level_count: 1,
|
||||
heights_m: [10.0],
|
||||
temp_k: [295.0],
|
||||
spfh: [0.010],
|
||||
pressure_pa: [101_325.0],
|
||||
u_wind_ms: [2.0],
|
||||
v_wind_ms: [1.0],
|
||||
tke_m2s2: [0.5]
|
||||
}
|
||||
|
||||
{:ok, _} =
|
||||
%HrrrNativeProfile{}
|
||||
|> HrrrNativeProfile.changeset(Map.merge(base, attrs))
|
||||
|> Repo.insert(on_conflict: :replace_all, conflict_target: [:valid_time, :lat, :lon])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -99,5 +99,76 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do
|
|||
assert result.remote_rx_power_0 == -57
|
||||
assert result.remote_tx_power == 19
|
||||
end
|
||||
|
||||
test "returns empty map for empty output" do
|
||||
assert SnmpClient.parse_af60_output("", "") == %{}
|
||||
end
|
||||
|
||||
test "converts tx/rx_capacity from kbps to mbps (integer division)" do
|
||||
# 450_500 kbps should round down to 450 mbps.
|
||||
station = """
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.7.170.211.109.42.26.137 = INTEGER: 450500
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.8.170.211.109.42.26.137 = INTEGER: 999
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_af60_output("", station)
|
||||
assert result.tx_capacity == 450
|
||||
# 999 kbps → 0 mbps (floor division)
|
||||
assert result.rx_capacity == 0
|
||||
end
|
||||
|
||||
test "converts link_uptime from centiseconds to seconds" do
|
||||
station = """
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.17.170.211.109.42.26.137 = INTEGER: 12345
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_af60_output("", station)
|
||||
assert result.link_uptime == 123
|
||||
end
|
||||
|
||||
test "ignores unknown OID columns in the station output" do
|
||||
station = """
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.3.170.211.109.42.26.137 = INTEGER: -42
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.99.170.211.109.42.26.137 = INTEGER: 7777
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_af60_output("", station)
|
||||
assert result.rx_power_0 == -42
|
||||
refute Map.has_key?(result, :col_99)
|
||||
assert map_size(result) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_snmpget_output/2 OID normalisation" do
|
||||
test "accepts SNMPv2-SMI::enterprises.* OID prefix" do
|
||||
output = """
|
||||
SNMPv2-SMI::enterprises.41112.1.3.2.1.11.1 = INTEGER: -61
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
assert result.rx_power_0 == -61
|
||||
end
|
||||
|
||||
test "skips lines that don't parse as OID = value" do
|
||||
output = """
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -55
|
||||
garbage line
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
assert result.rx_power_0 == -55
|
||||
assert map_size(result) == 1
|
||||
end
|
||||
|
||||
test "skips values that aren't parseable as integers" do
|
||||
output = """
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.11.1 = STRING: "not-a-number"
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.14.1 = INTEGER: -56
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
refute Map.has_key?(result, :rx_power_0)
|
||||
assert result.rx_power_1 == -56
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -110,5 +110,35 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do
|
|||
:telemetry.detach(handler_id)
|
||||
end
|
||||
end
|
||||
|
||||
test "broadcasts propagation:updated to PubSub after warming" do
|
||||
valid_time = ~U[2026-04-21 14:00:00Z]
|
||||
ScoresFile.write!(10_000, valid_time, sample_scores())
|
||||
|
||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
||||
|
||||
:ok = NotifyListener.handle_propagation_ready(valid_time)
|
||||
|
||||
assert_receive {:propagation_updated, [^valid_time]}, 1_000
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_info/2 raw dispatch" do
|
||||
test "notification with a malformed payload is tolerated (no crash)" do
|
||||
# The GenServer callback must not raise even when PostgreSQL emits
|
||||
# a malformed payload. Direct-dispatch the callback to avoid
|
||||
# standing up a real Postgrex.Notifications subscriber.
|
||||
assert {:noreply, %{ref: nil, pid: nil}} =
|
||||
NotifyListener.handle_info(
|
||||
{:notification, self(), make_ref(), "propagation_ready", "not-a-datetime"},
|
||||
%{ref: nil, pid: nil}
|
||||
)
|
||||
end
|
||||
|
||||
test "unrelated messages fall through without changing state" do
|
||||
state = %{ref: nil, pid: nil}
|
||||
assert {:noreply, ^state} = NotifyListener.handle_info(:something_else, state)
|
||||
assert {:noreply, ^state} = NotifyListener.handle_info({:EXIT, self(), :normal}, state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ defmodule Microwaveprop.Propagation.ProfilesFileTest do
|
|||
end
|
||||
|
||||
describe "MessagePack dual-format reader" do
|
||||
test "read/1 prefers .mp.gz over .etf.gz when both exist", %{dir: dir} do
|
||||
test "read/1 prefers .mp.gz over .etf.gz when both exist", %{dir: _dir} do
|
||||
vt = ~U[2026-04-19 14:00:00Z]
|
||||
|
||||
# Elixir writes the legacy ETF path.
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheReconcilerTest do
|
|||
# filters valid_times to `[now-1h, now+18h]`, so hardcoded fixtures
|
||||
# in the past drift out of scope as time passes. `current_hour/1`
|
||||
# returns an hour-aligned DateTime offset by N hours from now.
|
||||
defp current_hour(offset_hours \\ 0) do
|
||||
defp current_hour(offset_hours) do
|
||||
DateTime.utc_now()
|
||||
|> Map.put(:minute, 0)
|
||||
|> Map.put(:second, 0)
|
||||
|
|
|
|||
234
test/microwaveprop/space_weather/observation_changesets_test.exs
Normal file
234
test/microwaveprop/space_weather/observation_changesets_test.exs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
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
|
||||
|
|
@ -80,6 +80,58 @@ defmodule Microwaveprop.Terrain.ViewshedTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "effective_reach_km/3" do
|
||||
test "CLEAR path always gets the full range regardless of score" do
|
||||
analysis = %{verdict: "CLEAR", diffraction_db: 0.0}
|
||||
assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 50.0
|
||||
assert Viewshed.effective_reach_km(analysis, 50.0, 100) == 50.0
|
||||
end
|
||||
|
||||
test "FRESNEL_MINOR scales range to 90%" do
|
||||
analysis = %{verdict: "FRESNEL_MINOR", diffraction_db: 2.0}
|
||||
assert Viewshed.effective_reach_km(analysis, 50.0, 50) == 45.0
|
||||
end
|
||||
|
||||
test "FRESNEL_PARTIAL scales range to 70%" do
|
||||
analysis = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0}
|
||||
assert Viewshed.effective_reach_km(analysis, 50.0, 50) == 35.0
|
||||
end
|
||||
|
||||
test "BLOCKED with mild diffraction (<=3 dB) keeps 80% via terrain factor" do
|
||||
analysis = %{verdict: "BLOCKED", diffraction_db: 2.0}
|
||||
# score=0 so ducting factor is 0.05; terrain factor 0.8 wins.
|
||||
assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 40.0
|
||||
end
|
||||
|
||||
test "BLOCKED reduces monotonically as diffraction_db climbs" do
|
||||
ranges =
|
||||
Enum.map([2.0, 5.0, 10.0, 15.0, 30.0], fn db ->
|
||||
analysis = %{verdict: "BLOCKED", diffraction_db: db}
|
||||
Viewshed.effective_reach_km(analysis, 100.0, 0)
|
||||
end)
|
||||
|
||||
assert ranges == Enum.sort(ranges, :desc)
|
||||
assert List.first(ranges) > List.last(ranges)
|
||||
end
|
||||
|
||||
test "BLOCKED with high ducting score overrides terrain factor" do
|
||||
analysis = %{verdict: "BLOCKED", diffraction_db: 25.0}
|
||||
# At 25 dB, terrain factor is 0.05. A score of 85 gives ducting
|
||||
# factor 0.7 — that should be the dominant term.
|
||||
assert Viewshed.effective_reach_km(analysis, 100.0, 85) == 70.0
|
||||
end
|
||||
|
||||
test "BLOCKED ducting-score tiers: 80+ / 65+ / 50+ / 33+ / <33" do
|
||||
analysis = %{verdict: "BLOCKED", diffraction_db: 40.0}
|
||||
# terrain_reach_factor(40) = 0.05 in every row, so max() == ducting factor.
|
||||
assert Viewshed.effective_reach_km(analysis, 100.0, 90) == 70.0
|
||||
assert Viewshed.effective_reach_km(analysis, 100.0, 65) == 50.0
|
||||
assert Viewshed.effective_reach_km(analysis, 100.0, 50) == 30.0
|
||||
assert Viewshed.effective_reach_km(analysis, 100.0, 33) == 15.0
|
||||
assert Viewshed.effective_reach_km(analysis, 100.0, 10) == 5.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "analyse_ray/5" do
|
||||
test "returns full range for flat terrain with antenna heights" do
|
||||
profile =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.Weather.GefsClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Weather.GefsClient
|
||||
|
||||
|
|
@ -203,5 +204,81 @@ defmodule Microwaveprop.Weather.GefsClientTest do
|
|||
assert is_float(td)
|
||||
assert td < -30.0
|
||||
end
|
||||
|
||||
property "Td is never greater than T (physics floor)" do
|
||||
check all(
|
||||
temp_c <- float(min: -40.0, max: 50.0),
|
||||
rh_pct <- float(min: 0.01, max: 100.0)
|
||||
) do
|
||||
td = GefsClient.dewpoint_from_rh(temp_c, rh_pct)
|
||||
# Magnus formula: at RH=100 Td≈T; below saturation Td < T.
|
||||
# Allow 1.0 °C slack for the Magnus approximation near
|
||||
# saturation at the extremes of the temperature range.
|
||||
assert td <= temp_c + 1.0
|
||||
end
|
||||
end
|
||||
|
||||
property "Td increases monotonically with RH at fixed T" do
|
||||
check all(
|
||||
temp_c <- float(min: -20.0, max: 40.0),
|
||||
rh_low <- float(min: 1.0, max: 50.0),
|
||||
rh_delta <- float(min: 1.0, max: 50.0)
|
||||
) do
|
||||
rh_high = min(rh_low + rh_delta, 100.0)
|
||||
td_low = GefsClient.dewpoint_from_rh(temp_c, rh_low)
|
||||
td_high = GefsClient.dewpoint_from_rh(temp_c, rh_high)
|
||||
|
||||
assert td_low <= td_high
|
||||
end
|
||||
end
|
||||
|
||||
property "is always finite for any positive RH in [0.01, 100]" do
|
||||
check all(
|
||||
temp_c <- float(min: -50.0, max: 60.0),
|
||||
rh_pct <- float(min: 0.01, max: 100.0)
|
||||
) do
|
||||
td = GefsClient.dewpoint_from_rh(temp_c, rh_pct)
|
||||
assert is_float(td)
|
||||
# The bound checks also screen out NaN and ±Inf since both
|
||||
# `NaN < 1000.0` and `NaN > -1000.0` return false in Elixir.
|
||||
assert td > -1000.0
|
||||
assert td < 1000.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "nearest_run/1 property" do
|
||||
property "is idempotent and output hour is in {0, 6, 12, 18}" do
|
||||
check all(
|
||||
year <- integer(2020..2030),
|
||||
month <- integer(1..12),
|
||||
day <- integer(1..28),
|
||||
hour <- integer(0..23),
|
||||
minute <- integer(0..59),
|
||||
second <- integer(0..59)
|
||||
) do
|
||||
dt = %DateTime{
|
||||
year: year,
|
||||
month: month,
|
||||
day: day,
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
second: second,
|
||||
microsecond: {0, 0},
|
||||
std_offset: 0,
|
||||
utc_offset: 0,
|
||||
zone_abbr: "UTC",
|
||||
time_zone: "Etc/UTC"
|
||||
}
|
||||
|
||||
once = GefsClient.nearest_run(dt)
|
||||
twice = GefsClient.nearest_run(once)
|
||||
|
||||
assert DateTime.compare(once, twice) == :eq
|
||||
assert once.hour in [0, 6, 12, 18]
|
||||
assert once.minute == 0
|
||||
assert once.second == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -164,4 +164,71 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do
|
|||
HrrrClient.byte_ranges_for_messages(idx_entries, HrrrNativeClient.native_messages())
|
||||
end
|
||||
end
|
||||
|
||||
describe "native_level_count/0 and native_variables/0" do
|
||||
test "native_level_count is 50 (full hybrid stack)" do
|
||||
assert HrrrNativeClient.native_level_count() == 50
|
||||
end
|
||||
|
||||
test "native_variables lists exactly the 7 native-grid fields" do
|
||||
vars = HrrrNativeClient.native_variables()
|
||||
assert length(vars) == 7
|
||||
assert "TMP" in vars
|
||||
assert "SPFH" in vars
|
||||
assert "HGT" in vars
|
||||
assert "PRES" in vars
|
||||
assert "UGRD" in vars
|
||||
assert "VGRD" in vars
|
||||
assert "TKE" in vars
|
||||
end
|
||||
end
|
||||
|
||||
describe "native_messages/0 shape" do
|
||||
test "emits one message per (level, variable) across the 50-level hybrid stack" do
|
||||
messages = HrrrNativeClient.native_messages()
|
||||
expected = HrrrNativeClient.native_level_count() * length(HrrrNativeClient.native_variables())
|
||||
assert length(messages) == expected
|
||||
|
||||
# Every message carries a non-empty var and a hybrid-level string.
|
||||
Enum.each(messages, fn %{var: var, level: level} ->
|
||||
assert var in HrrrNativeClient.native_variables()
|
||||
assert level =~ ~r/\A\d+ hybrid level\z/
|
||||
end)
|
||||
end
|
||||
|
||||
test "each (var, level) pair is unique" do
|
||||
messages = HrrrNativeClient.native_messages()
|
||||
unique = messages |> Enum.uniq_by(fn %{var: v, level: l} -> {v, l} end) |> length()
|
||||
assert unique == length(messages)
|
||||
end
|
||||
end
|
||||
|
||||
describe "duct_messages/0 and duct_byte_ranges/1" do
|
||||
test "emits one message per (level, duct-var) across the 50-level stack (4 vars)" do
|
||||
messages = HrrrNativeClient.duct_messages()
|
||||
assert length(messages) == 50 * 4
|
||||
|
||||
# Exactly the duct-detection variables — no UGRD/VGRD/TKE.
|
||||
vars = messages |> Enum.map(& &1.var) |> Enum.uniq() |> Enum.sort()
|
||||
assert vars == ["HGT", "PRES", "SPFH", "TMP"]
|
||||
end
|
||||
|
||||
test "duct_byte_ranges/1 returns integer ranges for matching idx entries" do
|
||||
idx_entries = [
|
||||
%{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"},
|
||||
%{msg: 2, offset: 1000, var: "SPFH", level: "1 hybrid level"},
|
||||
%{msg: 3, offset: 2000, var: "UGRD", level: "1 hybrid level"},
|
||||
%{msg: 4, offset: 3000, var: "HGT", level: "1 hybrid level"}
|
||||
]
|
||||
|
||||
ranges = HrrrNativeClient.duct_byte_ranges(idx_entries)
|
||||
# UGRD is not in the duct set, so only 3 ranges should come back.
|
||||
assert length(ranges) == 3
|
||||
|
||||
Enum.each(ranges, fn {s, e} ->
|
||||
assert is_integer(s) and is_integer(e)
|
||||
assert s < e
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.Weather.NexradClientTest do
|
||||
use ExUnit.Case, async: false
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Weather.NexradCache
|
||||
alias Microwaveprop.Weather.NexradClient
|
||||
|
|
@ -215,5 +216,71 @@ defmodule Microwaveprop.Weather.NexradClientTest do
|
|||
assert dbz > 0.0
|
||||
assert dbz < 95.0
|
||||
end
|
||||
|
||||
property "is monotonic non-decreasing across [1, 255]" do
|
||||
check all(
|
||||
a <- integer(1..254),
|
||||
delta <- integer(0..254)
|
||||
) do
|
||||
b = min(a + delta, 255)
|
||||
assert NexradClient.pixel_to_dbz(a) <= NexradClient.pixel_to_dbz(b)
|
||||
end
|
||||
end
|
||||
|
||||
property "output is in [-30.0, 95.0] for any byte value [1, 255]" do
|
||||
check all(value <- integer(1..255)) do
|
||||
dbz = NexradClient.pixel_to_dbz(value)
|
||||
assert dbz >= -30.0
|
||||
assert dbz <= 95.0 + 1.0e-9
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "latlon_to_pixel/2 invariants" do
|
||||
property "matching lat/lon corners snap to pixel corners exactly" do
|
||||
# The CONUS grid is a 12201×5401 frame anchored at (50N, -126W)
|
||||
# with 0.005° resolution. Every cell center whose (lat, lon)
|
||||
# is aligned with the grid must round-trip to the integer pixel
|
||||
# coordinates the module's constants imply.
|
||||
check all(
|
||||
x <- integer(0..12_200),
|
||||
y <- integer(0..5400)
|
||||
) do
|
||||
lat = 50.0 - y * 0.005
|
||||
lon = -126.0 + x * 0.005
|
||||
{px, py} = NexradClient.latlon_to_pixel(lat, lon)
|
||||
assert px == x
|
||||
assert py == y
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "extract_box/5 edge cases" do
|
||||
test "box partially off the left edge returns whatever pixels fall inside" do
|
||||
# 4-row × 10-col frame, every pixel hot.
|
||||
pixels = :binary.copy(<<128>>, 40)
|
||||
# Centre x=0 → x_min=0, x_max=2. Centre y=0 → y_min=0, y_max=2.
|
||||
result = NexradClient.extract_box(pixels, 10, 0, 0, 2)
|
||||
assert result.pixel_count > 0
|
||||
assert result.max_reflectivity_dbz > 0.0
|
||||
end
|
||||
|
||||
test "box entirely off the right edge returns no-data (no pixels)" do
|
||||
pixels = :binary.copy(<<128>>, 40)
|
||||
# width=10, cx=50 → x_min=48, x_max=9 → empty range after clamp.
|
||||
result = NexradClient.extract_box(pixels, 10, 50, 2, 2)
|
||||
assert result.pixel_count == 0
|
||||
assert result.mean_reflectivity_dbz == 0.0
|
||||
assert result.max_reflectivity_dbz == 0.0
|
||||
assert result.texture_variance == 0.0
|
||||
end
|
||||
|
||||
test "zero pixels in box yields the no-echo stats tuple" do
|
||||
pixels = :binary.copy(<<0>>, 40)
|
||||
result = NexradClient.extract_box(pixels, 10, 5, 2, 2)
|
||||
# Every pixel is 0 → none enter the filtered dbz_values list.
|
||||
assert result.pixel_count == 0
|
||||
assert result.mean_reflectivity_dbz == 0.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.RadarFrameWorkerTest do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp insert_contact(attrs \\ %{}) do
|
||||
defp insert_contact(attrs) do
|
||||
default = %{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
|
|||
user
|
||||
end
|
||||
|
||||
defp create_contact(attrs \\ %{}) do
|
||||
defp create_contact(attrs) do
|
||||
default = %{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue