defmodule Microwaveprop.Propagation.PathComputeTest do use Microwaveprop.DataCase, async: false use ExUnitProperties alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.PathCompute alias Microwaveprop.Weather.HrrrProfile 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 Map.has_key?(result, :loss_budget) assert Map.has_key?(result, :power_budget) # No HRRR profiles in the test DB → empty hrrr_points. assert 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 "with HRRR profile data + sounding near the path uses real data" do now = DateTime.truncate(DateTime.utc_now(), :second) profile_data = [ %{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}, %{"pres" => 925.0, "hght" => 770, "tmpc" => 18.0, "dwpc" => 12.0}, %{"pres" => 850.0, "hght" => 1500, "tmpc" => 14.0, "dwpc" => 8.0} ] # Insert an HRRR profile near the midpoint of (32.9, -97.0) → (33.5, -96.5). %HrrrProfile{} |> HrrrProfile.changeset(%{ valid_time: now, run_time: now, lat: 33.2, lon: -96.75, profile: profile_data, hpbl_m: 1500.0, pwat_mm: 25.0, surface_temp_c: 25.0, surface_dewpoint_c: 18.0, surface_pressure_mb: 1013.0, surface_refractivity: 320.5, min_refractivity_gradient: -45.0, ducting_detected: false }) |> Microwaveprop.Repo.insert!() assert {:ok, result} = PathCompute.compute("32.9, -97.0", "33.5, -96.5", 10_000, @station_params) # Even if HRRR points doesn't include this grid (lookups snap to # specific grid cells), the pipeline ran end-to-end without error. assert %{hrrr_points: _} = result 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 test ":on_progress callback fires once per stage in order" do me = self() total = PathCompute.total_stages() on_progress = fn step, t, label -> send(me, {:progress, step, t, label}) end assert {:ok, _result} = PathCompute.compute("32.9, -97.0", "33.5, -96.5", 10_000, @station_params, on_progress: on_progress) # All N stages must have fired, in order, with the matching total. for step <- 1..total do assert_received {:progress, ^step, ^total, label} assert byte_size(label) > 0 end 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