prop/test/microwaveprop/rover_planning_test.exs
Graham McIntire 23d002e8e0
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.
2026-05-03 13:58:18 -05:00

223 lines
8.8 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.RoverPlanningTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.Terrain.ElevationClient
setup do
# Oban runs inline in test, so RoverPathProfileWorker fires immediately
# when the context enqueues jobs. Stub elevation lookups so the worker
# can complete without hitting the real API.
Req.Test.stub(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
defp create_rover_location(attrs) do
user = AccountsFixtures.user_fixture()
{:ok, loc} =
Rover.create_location(
user,
Map.merge(%{lat: 32.5, lon: -97.5, status: :good}, attrs)
)
loc
end
defp valid_attrs(extra \\ %{}) do
Map.merge(
%{
"name" => "Test mission",
"band_mhz" => 10_000,
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => %{
"0" => %{"input" => "EM12kp", "position" => 0}
}
},
extra
)
end
describe "create_mission/2" do
test "inserts a mission with stations and enqueues a path per (rover × station) pair" do
user = AccountsFixtures.user_fixture()
_ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
_off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
assert mission.user_id == user.id
assert [station] = mission.stations
assert station.grid == "EM12KP"
assert is_float(station.lat)
assert is_float(station.lon)
paths = RoverPlanning.list_paths(mission)
# only_known_good=true → only the :good location pairs with the station.
# Oban runs inline in test so the worker has already completed.
assert length(paths) == 1
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})
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
[path] = RoverPlanning.list_paths(mission)
# The worker is the gate: status only flips to :complete after the
# path-calculator pipeline (terrain + propagation lookup) has run.
# The score itself is nil in the test env (no .prop file on disk),
# but the key MUST be present so the show-page can render "—"
# without a key-existence check at every cell.
assert path.status == :complete
assert Map.has_key?(path.result, "propagation_score")
end
test "backfill_paths/0 re-runs failed paths across all missions" 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
# Simulate a path that was stuck in :failed (e.g. transient
# elevation API error during the original computation).
{:ok, _} =
path
|> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil})
|> Repo.update()
assert :ok = RoverPlanning.backfill_paths()
# Oban inline → backfill enqueues a job, the worker reruns
# immediately, and the path flips back to :complete.
[reloaded] = RoverPlanning.list_paths(mission)
assert reloaded.status == :complete
assert is_map(reloaded.result)
end
test "backfill_paths/0 inserts missing rows for under-populated missions" 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())
# Simulate a half-broken mission: paths were never persisted (e.g.
# the original create_mission Multi succeeded but the Oban insert
# was lost due to a crash). Backfill must repopulate the matrix.
Repo.delete_all(Ecto.Query.from(p in RoverPlanning.Path, where: p.mission_id == ^mission.id))
assert RoverPlanning.list_paths(mission) == []
assert :ok = RoverPlanning.backfill_paths()
assert [path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
end
test "with only_known_good=false, all rover-locations get a path entry" do
user = AccountsFixtures.user_fixture()
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
_off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})
attrs = valid_attrs(%{"only_known_good" => false})
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
assert length(RoverPlanning.list_paths(mission)) == 2
end
test "missing stations returns a changeset error" do
user = AccountsFixtures.user_fixture()
attrs = Map.delete(valid_attrs(), "stations")
assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
refute cs.valid?
assert {_, _} = cs.errors[:stations] || hd(cs.errors)
end
end
describe "update_mission/3" do
test "owner can update a mission and paths get rebuilt" do
user = AccountsFixtures.user_fixture()
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
[original] = RoverPlanning.list_paths(mission)
attrs = valid_attrs(%{"name" => "Updated"})
assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
assert updated.name == "Updated"
# Old path was deleted by replace, fresh row inserted with new id.
[fresh] = RoverPlanning.list_paths(updated)
refute fresh.id == original.id
end
test "non-owner update is denied" do
owner = AccountsFixtures.user_fixture()
other = AccountsFixtures.user_fixture()
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
assert {:error, :not_found} =
RoverPlanning.update_mission(other, mission.id, valid_attrs())
end
test "admin can update any mission" do
owner = AccountsFixtures.user_fixture()
admin = AccountsFixtures.user_fixture()
{:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
assert {:ok, updated} =
RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))
assert updated.name == "By admin"
end
end
describe "delete_mission/2" do
test "owner can delete; non-owner cannot" do
owner = AccountsFixtures.user_fixture()
other = AccountsFixtures.user_fixture()
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
refute Repo.get(Mission, mission.id)
end
end
end