feat(rover-planning): cache link-budget loss components per path

The path-profile worker now also computes the baseline link-loss
budget — free-space path loss, oxygen absorption, baseline H2O loss
(at 7.5 g/m^3 default humidity), plus the already-cached terrain
diffraction — and stores all four numbers in Path.result. The show
page renders the per-row total in a new Loss column. /path still
recomputes against live HRRR weather; this is the offline-readable
cached snapshot.
This commit is contained in:
Graham McIntire 2026-05-03 13:58:18 -05:00
parent 327b4fc561
commit 23d002e8e0
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 101 additions and 2 deletions

View file

@ -23,14 +23,21 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
import Ecto.Query import Ecto.Query
alias Microwaveprop.Geo alias Microwaveprop.Geo
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ScoresFile alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.RoverPlanning.Path alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis 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 require Logger
@default_abs_humidity_gm3 7.5
@impl Oban.Worker @impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do def backoff(%Oban.Job{attempt: attempt}) do
min(60 * Integer.pow(2, attempt - 1), 3600) min(60 * Integer.pow(2, attempt - 1), 3600)
@ -89,7 +96,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
midlon = (rover.lon + station.lon) / 2 midlon = (rover.lon + station.lon) / 2
propagation_score = lookup_propagation_score(mission.band_mhz, midlat, midlon) 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} -> {:error, reason} ->
store_failed(path, "elevation profile failed: #{inspect(reason)}") store_failed(path, "elevation profile failed: #{inspect(reason)}")
@ -101,7 +111,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
reraise e, __STACKTRACE__ reraise e, __STACKTRACE__
end 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 = points =
Enum.map(profile, fn p -> Enum.map(profile, fn p ->
%{ %{
@ -124,6 +134,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
"verdict" => to_string(analysis.verdict), "verdict" => to_string(analysis.verdict),
"sample_count" => length(profile), "sample_count" => length(profile),
"propagation_score" => propagation_score, "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 "path_points" => points
} }
@ -173,6 +187,30 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
end end
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 # Look up the propagation grid score (0-100) for the midpoint of a
# path at the most recent forecast time available on disk. Returns # path at the most recent forecast time available on disk. Returns
# nil when no scores file exists for this band yet — callers (the # nil when no scores file exists for this band yet — callers (the

View file

@ -231,6 +231,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp format_distance(_), 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 # Two-line station identity for the path table. Returns
# `{primary, secondary}` — primary is the callsign (or grid when no # `{primary, secondary}` — primary is the callsign (or grid when no
# callsign), secondary is a Maidenhead grid (stored or derived from # callsign), secondary is a Maidenhead grid (stored or derived from
@ -476,6 +479,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
<th>Station</th> <th>Station</th>
<th>Status</th> <th>Status</th>
<th>Distance</th> <th>Distance</th>
<th>Loss</th>
<th>Verdict</th> <th>Verdict</th>
<th>Propagation</th> <th>Propagation</th>
</tr> </tr>
@ -498,6 +502,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
<td class="text-xs"> <td class="text-xs">
{if path.result, do: format_distance(path.result["distance_km"]), else: ""} {if path.result, do: format_distance(path.result["distance_km"]), else: ""}
</td> </td>
<td class="text-xs">
{if path.result,
do: format_loss(path.result["total_baseline_loss_db"]),
else: ""}
</td>
<td> <td>
{if path.result, do: verdict_badge(path.result["verdict"]), else: ""} {if path.result, do: verdict_badge(path.result["verdict"]), else: ""}
</td> </td>

View file

@ -69,6 +69,29 @@ defmodule Microwaveprop.RoverPlanningTest do
assert hd(paths).status in [:complete, :failed] assert hd(paths).status in [:complete, :failed]
end 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 test "completed path stores a propagation_score key (may be nil without scores file)" do
user = AccountsFixtures.user_fixture() user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

View file

@ -213,6 +213,35 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
assert html =~ "78" assert html =~ "78"
end 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 test "propagation column renders dash when score is missing", %{conn: conn} do
user = AccountsFixtures.user_fixture() user = AccountsFixtures.user_fixture()
mission = create_mission(user, "No-score mission") mission = create_mission(user, "No-score mission")