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:
Graham McIntire 2026-04-23 13:28:41 -05:00
parent 8c0865482c
commit 764643bc62
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
15 changed files with 767 additions and 45 deletions

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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)

View 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

View file

@ -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 =

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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",