- Pskr.Client: 0% -> 18% (init/standby/handle_info/terminate) - RoverPlanningLive.PathShow: 0% -> 82% (mount/load_path) - Rover.CandidateDetail: 0% -> partial (summarize/5 with stations) - Mix.Tasks.Weather.RebatchAsos: 0% -> covered (wrapper run/1) - HrrrNativeClient: 57% -> 67% (fetch_native_duct_grid error paths) - RoverLive: 40% -> 48% (toggle/delete/update_station_grid logged-in) - PathLive: 62% -> 63% (path_forecast_detail + rover_path_id branches) - PathCompute: 64% -> 64% (compute/4 full pipeline + resolve_location) - SkewtLive: 29% -> 31% (search/select_time event no-ops) - SkewtLocationResolver: 38% -> 60%+ (callsign cache + error paths) 3557 tests passing.
234 lines
8.6 KiB
Elixir
234 lines
8.6 KiB
Elixir
defmodule Microwaveprop.Propagation.PathComputeTest do
|
|
use Microwaveprop.DataCase, async: false
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Propagation.PathCompute
|
|
|
|
describe "compute_loss_budget/5" do
|
|
test "fspl increases with distance" do
|
|
band = BandConfig.get(10_000)
|
|
near = PathCompute.compute_loss_budget(10.0, 10.0, band, nil, nil)
|
|
far = PathCompute.compute_loss_budget(100.0, 10.0, band, nil, nil)
|
|
assert near.fspl < far.fspl
|
|
assert near.total < far.total
|
|
end
|
|
|
|
test "fspl increases with frequency" do
|
|
band_10g = BandConfig.get(10_000)
|
|
band_24g = BandConfig.get(24_000)
|
|
low = PathCompute.compute_loss_budget(50.0, 10.0, band_10g, nil, nil)
|
|
high = PathCompute.compute_loss_budget(50.0, 24.0, band_24g, nil, nil)
|
|
assert low.fspl < high.fspl
|
|
end
|
|
|
|
test "gas absorption losses with default humidity when conditions absent" do
|
|
band = BandConfig.get(10_000)
|
|
result = PathCompute.compute_loss_budget(50.0, 10.0, band, nil, nil)
|
|
assert result.o2 >= 0
|
|
assert result.h2o >= 0
|
|
end
|
|
|
|
test "higher bands have more h2o absorption" do
|
|
band_10g = BandConfig.get(10_000)
|
|
band_75g = BandConfig.get(75_000)
|
|
low = PathCompute.compute_loss_budget(50.0, 10.0, band_10g, nil, nil)
|
|
high = PathCompute.compute_loss_budget(50.0, 75.0, band_75g, nil, nil)
|
|
assert low.h2o <= high.h2o
|
|
end
|
|
|
|
test "rain loss is zero when there's no rain" do
|
|
band = BandConfig.get(10_000)
|
|
conditions = %{rain_rate_mmhr: 0.0, abs_humidity: 7.5}
|
|
result = PathCompute.compute_loss_budget(50.0, 10.0, band, nil, conditions)
|
|
assert result.rain == 0.0
|
|
end
|
|
|
|
test "rain loss is positive when it's raining" do
|
|
band = BandConfig.get(10_000)
|
|
conditions = %{rain_rate_mmhr: 5.0, abs_humidity: 7.5}
|
|
result = PathCompute.compute_loss_budget(50.0, 10.0, band, nil, conditions)
|
|
assert result.rain > 0
|
|
end
|
|
|
|
test "diffraction loss is included when terrain result is present" do
|
|
band = BandConfig.get(10_000)
|
|
terrain = %{analysis: %{diffraction_db: 15.0}}
|
|
result = PathCompute.compute_loss_budget(50.0, 10.0, band, terrain, nil)
|
|
assert result.diffraction == 15.0
|
|
end
|
|
|
|
test "diffraction loss is zero when no terrain result" do
|
|
band = BandConfig.get(10_000)
|
|
result = PathCompute.compute_loss_budget(50.0, 10.0, band, nil, nil)
|
|
assert result.diffraction == 0.0
|
|
end
|
|
end
|
|
|
|
describe "compute_loss_budget/5 property" do
|
|
property "total is always >= fspl" do
|
|
check all(
|
|
dist_km <- float(min: 0.1, max: 500),
|
|
band_config = BandConfig.get(10_000)
|
|
) do
|
|
result = PathCompute.compute_loss_budget(dist_km, 10.0, band_config, nil, nil)
|
|
assert result.total >= result.fspl
|
|
assert result.total > 0
|
|
end
|
|
end
|
|
|
|
property "all loss components are non-negative across known bands" do
|
|
bands = [50, 144, 222, 432, 902, 1_296, 2_304, 3_400, 5_760, 10_000, 24_000, 47_000, 75_000]
|
|
|
|
check all(
|
|
dist_km <- float(min: 0.1, max: 500),
|
|
band_mhz <- member_of(bands)
|
|
) do
|
|
band_config = BandConfig.get(band_mhz)
|
|
freq_ghz = band_mhz / 1000
|
|
result = PathCompute.compute_loss_budget(dist_km, freq_ghz, band_config, nil, nil)
|
|
assert result.fspl >= 0
|
|
assert result.o2 >= 0
|
|
assert result.h2o >= 0
|
|
assert result.rain >= 0
|
|
assert result.diffraction >= 0
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "resolve_location/1" do
|
|
test "parses a coordinate pair" do
|
|
assert {:ok, %{lat: 32.9, lon: -96.8}} = PathCompute.resolve_location("32.9, -96.8")
|
|
end
|
|
|
|
test "parses a Maidenhead grid" do
|
|
assert {:ok, %{kind: :grid, label: "EM12"}} = PathCompute.resolve_location("EM12")
|
|
end
|
|
|
|
test "blank input returns the location-required error" do
|
|
assert {:error, "Location is required"} = PathCompute.resolve_location("")
|
|
assert {:error, "Location is required"} = PathCompute.resolve_location(nil)
|
|
end
|
|
end
|
|
|
|
describe "build_ionosphere_readout/4" do
|
|
test "returns nil for non-ionosphere bands (microwave)" do
|
|
assert PathCompute.build_ionosphere_readout(10_000, 32.9, -96.8, 100.0) == nil
|
|
assert PathCompute.build_ionosphere_readout(24_000, 32.9, -96.8, 100.0) == nil
|
|
end
|
|
|
|
test "returns nil for bands the readout does not handle (e.g. 902 MHz)" do
|
|
# The function only opens up for [50, 144, 222, 432]; 902 falls
|
|
# through to the catch-all clause.
|
|
assert PathCompute.build_ionosphere_readout(902, 32.9, -96.8, 100.0) == nil
|
|
end
|
|
|
|
test "returns nil when band is supported but no ionosphere data is available" do
|
|
# 6m and 2m hit the `Ionosphere.nearest_foes/2` path; with an empty
|
|
# test DB the function returns `{:error, :no_data}` and the
|
|
# readout falls through to the catch-all `nil` arm.
|
|
assert PathCompute.build_ionosphere_readout(50, 32.9, -96.8, 100.0) == nil
|
|
assert PathCompute.build_ionosphere_readout(144, 32.9, -96.8, 100.0) == nil
|
|
end
|
|
end
|
|
|
|
describe "compute/4 — full pipeline" do
|
|
setup do
|
|
Req.Test.stub(Microwaveprop.Terrain.ElevationClient, fn conn ->
|
|
params = Plug.Conn.fetch_query_params(conn).query_params
|
|
lat_count = params["latitude"] |> String.split(",") |> length()
|
|
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
@station_params %{
|
|
src_height_m: 10.0,
|
|
dst_height_m: 10.0,
|
|
tx_power_dbm: 30.0,
|
|
src_gain_dbi: 20.0,
|
|
dst_gain_dbi: 20.0
|
|
}
|
|
|
|
test "computes a result for a coordinate-pair input" do
|
|
assert {:ok, result} =
|
|
PathCompute.compute("32.9, -97.0", "33.5, -96.5", 10_000, @station_params)
|
|
|
|
assert result.band_mhz == 10_000
|
|
assert result.dist_km > 0
|
|
assert is_float(result.bearing)
|
|
assert is_map(result.loss_budget)
|
|
assert is_map(result.power_budget)
|
|
# No HRRR profiles in the test DB → empty hrrr_points.
|
|
assert is_list(result.hrrr_points)
|
|
# Microwave band → no ionosphere readout.
|
|
assert result.ionosphere == nil
|
|
# No sounding rows in the test DB.
|
|
assert result.sounding == nil
|
|
end
|
|
|
|
test "returns {:error, _} when source resolution fails" do
|
|
assert {:error, _msg} =
|
|
PathCompute.compute("", "32.9, -97.0", 10_000, @station_params)
|
|
end
|
|
|
|
test "returns {:error, _} when destination resolution fails" do
|
|
assert {:error, _msg} =
|
|
PathCompute.compute("32.9, -97.0", "", 10_000, @station_params)
|
|
end
|
|
|
|
test "falls back to a default band config for unknown band_mhz" do
|
|
# Unknown band falls back to BandConfig.get(10_000).
|
|
assert {:ok, result} =
|
|
PathCompute.compute("32.9, -97.0", "33.5, -96.5", 9_999, @station_params)
|
|
|
|
assert result.band_mhz == 9_999
|
|
# Result band_config should be the 10G default.
|
|
assert result.band_config
|
|
end
|
|
|
|
test "60 GHz band exercises the high-h2o-absorption path" do
|
|
assert {:ok, result} =
|
|
PathCompute.compute("32.9, -97.0", "33.5, -96.5", 47_000, @station_params)
|
|
|
|
# 47 GHz has substantially more h2o absorption than 10 GHz.
|
|
assert result.loss_budget.h2o > 0
|
|
end
|
|
|
|
test "Maidenhead grid input round-trips through resolve" do
|
|
assert {:ok, result} =
|
|
PathCompute.compute("EM12", "EM13", 10_000, @station_params)
|
|
|
|
assert result.dist_km > 0
|
|
assert result.source.kind == :grid
|
|
assert result.destination.kind == :grid
|
|
end
|
|
end
|
|
|
|
describe "compute_power_budget/2" do
|
|
test "computes rx power and margins" do
|
|
loss = %{total: 140.0, fspl: 130.0, o2: 3.0, h2o: 5.0, rain: 0.0, diffraction: 2.0}
|
|
params = %{tx_power_dbm: 30.0, src_gain_dbi: 20.0, dst_gain_dbi: 20.0}
|
|
|
|
result = PathCompute.compute_power_budget(params, loss)
|
|
|
|
# EIRP = tx_power + src_gain = 50 dBm
|
|
assert_in_delta result.eirp_dbm, 50.0, 0.1
|
|
# rx_power = eirp - loss + dst_gain
|
|
assert_in_delta result.rx_power_dbm, -70.0, 0.1
|
|
# margin_cw = rx_power - (-140)
|
|
assert_in_delta result.margin_cw, 70.0, 0.1
|
|
end
|
|
|
|
test "higher tx power improves margins" do
|
|
loss = %{total: 140.0, fspl: 130.0, o2: 3.0, h2o: 5.0, rain: 0.0, diffraction: 2.0}
|
|
|
|
low = PathCompute.compute_power_budget(%{tx_power_dbm: 20.0, src_gain_dbi: 10.0, dst_gain_dbi: 10.0}, loss)
|
|
high = PathCompute.compute_power_budget(%{tx_power_dbm: 30.0, src_gain_dbi: 20.0, dst_gain_dbi: 20.0}, loss)
|
|
|
|
assert low.rx_power_dbm < high.rx_power_dbm
|
|
assert low.margin_cw < high.margin_cw
|
|
end
|
|
end
|
|
end
|