diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex index cee7b4cc..8f1028cc 100644 --- a/lib/microwaveprop/workers/rover_path_profile_worker.ex +++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex @@ -23,14 +23,21 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do import Ecto.Query alias Microwaveprop.Geo + alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.ScoresFile alias Microwaveprop.Repo alias Microwaveprop.RoverPlanning.Path alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.TerrainAnalysis + # Default surface absolute humidity (g/m³) — ~7.5 is the + # PathLive fallback when no live HRRR sample is available. Used to + # produce a "baseline" cached H₂O loss; the live `/path` page + # recomputes this from current weather. require Logger + @default_abs_humidity_gm3 7.5 + @impl Oban.Worker def backoff(%Oban.Job{attempt: attempt}) do min(60 * Integer.pow(2, attempt - 1), 3600) @@ -89,7 +96,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do midlon = (rover.lon + station.lon) / 2 propagation_score = lookup_propagation_score(mission.band_mhz, midlat, midlon) - store_complete(path, profile, analysis, dist_km, bearing, propagation_score) + loss_budget = + compute_loss_budget(dist_km, mission.band_mhz, freq_ghz, analysis.diffraction_db) + + store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget) {:error, reason} -> store_failed(path, "elevation profile failed: #{inspect(reason)}") @@ -101,7 +111,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do reraise e, __STACKTRACE__ end - defp store_complete(path, profile, analysis, dist_km, bearing, propagation_score) do + defp store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget) do points = Enum.map(profile, fn p -> %{ @@ -124,6 +134,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do "verdict" => to_string(analysis.verdict), "sample_count" => length(profile), "propagation_score" => propagation_score, + "free_space_loss_db" => loss_budget.free_space_loss_db, + "oxygen_loss_db" => loss_budget.oxygen_loss_db, + "humidity_loss_db_baseline" => loss_budget.humidity_loss_db_baseline, + "total_baseline_loss_db" => loss_budget.total_baseline_loss_db, "path_points" => points } @@ -173,6 +187,30 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do end end + # Cached baseline link-loss components for a path. Mirrors the + # `compute_loss_budget` math in PathLive but without live HRRR + # weather: humidity defaults to @default_abs_humidity_gm3 and rain is + # treated as zero. The `/path` page recomputes these against current + # conditions; this snapshot is what the rover-planning show page + # renders without needing HRRR access. + defp compute_loss_budget(dist_km, band_mhz, freq_ghz, diffraction_db) do + band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) + freq_mhz = freq_ghz * 1000 + + fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44 + o2_loss = (band_config.o2_db_km || 0.0) * dist_km + h2o_loss = (band_config.h2o_coeff || 0.0) * @default_abs_humidity_gm3 * dist_km + diffraction = diffraction_db * 1.0 + total = fspl + o2_loss + h2o_loss + diffraction + + %{ + free_space_loss_db: Float.round(fspl, 1), + oxygen_loss_db: Float.round(o2_loss, 2), + humidity_loss_db_baseline: Float.round(h2o_loss, 2), + total_baseline_loss_db: Float.round(total, 1) + } + end + # Look up the propagation grid score (0-100) for the midpoint of a # path at the most recent forecast time available on disk. Returns # nil when no scores file exists for this band yet — callers (the diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex index 02a7034e..223f0073 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/show.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex @@ -231,6 +231,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do defp format_distance(_), do: "—" + defp format_loss(db) when is_number(db), do: "#{Float.round(db * 1.0, 1)} dB" + defp format_loss(_), do: "—" + # Two-line station identity for the path table. Returns # `{primary, secondary}` — primary is the callsign (or grid when no # callsign), secondary is a Maidenhead grid (stored or derived from @@ -476,6 +479,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do Station Status Distance + Loss Verdict Propagation @@ -498,6 +502,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do {if path.result, do: format_distance(path.result["distance_km"]), else: "—"} + + {if path.result, + do: format_loss(path.result["total_baseline_loss_db"]), + else: "—"} + {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"} diff --git a/test/microwaveprop/rover_planning_test.exs b/test/microwaveprop/rover_planning_test.exs index 0f1ee25b..1069c587 100644 --- a/test/microwaveprop/rover_planning_test.exs +++ b/test/microwaveprop/rover_planning_test.exs @@ -69,6 +69,29 @@ defmodule Microwaveprop.RoverPlanningTest do assert hd(paths).status in [:complete, :failed] end + test "completed path caches the link-budget loss components" do + user = AccountsFixtures.user_fixture() + _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) + + assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs()) + [path] = RoverPlanning.list_paths(mission) + assert path.status == :complete + + # The result map MUST carry the cached loss-budget so the show + # page (and any future caller) can render link math without + # re-running the computation. Values come from the band's + # absorption coefficients × distance + free-space path loss + the + # already-cached terrain diffraction. Stored as floats. + assert is_float(path.result["free_space_loss_db"]) + assert is_float(path.result["oxygen_loss_db"]) + assert is_float(path.result["humidity_loss_db_baseline"]) + assert is_float(path.result["total_baseline_loss_db"]) + # Free-space loss for a ~140 km, 10 GHz path is ~155 dB; sanity + # check it's in the right order of magnitude (not zero, not 1000). + assert path.result["free_space_loss_db"] > 100 + assert path.result["free_space_loss_db"] < 200 + end + test "completed path stores a propagation_score key (may be nil without scores file)" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs index 69cad704..e0ad5c0f 100644 --- a/test/microwaveprop_web/live/rover_planning_live_test.exs +++ b/test/microwaveprop_web/live/rover_planning_live_test.exs @@ -213,6 +213,35 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do assert html =~ "78" end + test "loss column renders the cached total baseline path loss", %{conn: conn} do + user = AccountsFixtures.user_fixture() + mission = create_mission(user, "Loss column mission") + + [path | _] = Repo.all(Path) + + {:ok, _} = + path + |> Ecto.Changeset.change(%{ + status: :complete, + result: %{ + "distance_km" => 12.0, + "verdict" => "CLEAR", + "free_space_loss_db" => 134.7, + "oxygen_loss_db" => 0.08, + "humidity_loss_db_baseline" => 0.18, + "total_baseline_loss_db" => 135.0 + } + }) + |> Repo.update() + + {:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}") + assert html =~ "Loss" + # The TOTAL is what the user actually cares about at-a-glance — + # FSPL alone undersells humid-band paths. Show with one decimal + + # "dB" suffix. + assert html =~ "135.0 dB" + end + test "propagation column renders dash when score is missing", %{conn: conn} do user = AccountsFixtures.user_fixture() mission = create_mission(user, "No-score mission")