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:
parent
327b4fc561
commit
23d002e8e0
4 changed files with 101 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue