diff --git a/lib/microwaveprop/rover_planning.ex b/lib/microwaveprop/rover_planning.ex index 9ab3cebe..dfc70f37 100644 --- a/lib/microwaveprop/rover_planning.ex +++ b/lib/microwaveprop/rover_planning.ex @@ -112,16 +112,46 @@ defmodule Microwaveprop.RoverPlanning do end @doc """ - Wipes the existing path matrix for a mission and re-enqueues all - pairings. Called after a mission edit when the station list or - `only_known_good` toggle could have shifted the matrix. + Reconciles the path matrix for a mission against the current set of + (rover_location × station × band) tuples. Deletes paths for tuples + that no longer apply, leaves :complete paths in place when they're + still relevant, and enqueues new pending paths for any tuple missing + from the table. Called after a mission edit, or after the user + adds/removes a rover site. + + Aliased as `replace_mission_paths/1` for backwards compatibility + with callers that expected the previous destructive behaviour; + internally both go through the same reconcile path. """ - @spec replace_mission_paths(Mission.t()) :: :ok - def replace_mission_paths(%Mission{} = mission) do - Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id)) - enqueue_paths_for(mission) + @spec reconcile_mission_paths(Mission.t()) :: :ok + def reconcile_mission_paths(%Mission{} = mission) do + desired = MapSet.new(desired_tuples(mission)) + + actual_paths = + Repo.all(from(p in Path, where: p.mission_id == ^mission.id)) + + actual = MapSet.new(actual_paths, &{&1.rover_location_id, &1.station_id, &1.band_mhz}) + + stale_ids = + actual_paths + |> Enum.reject(&MapSet.member?(desired, {&1.rover_location_id, &1.station_id, &1.band_mhz})) + |> Enum.map(& &1.id) + + if stale_ids != [] do + Repo.delete_all(from(p in Path, where: p.id in ^stale_ids)) + end + + desired + |> MapSet.difference(actual) + |> Enum.to_list() + |> insert_pending_paths_and_jobs(mission) + + :ok end + @spec replace_mission_paths(Mission.t()) :: :ok + defdelegate replace_mission_paths(mission), to: __MODULE__, as: :reconcile_mission_paths + @doc """ Walks every mission and re-enqueues any (rover_location × station) pair whose `Path` is missing or not `:complete`. Existing complete @@ -143,17 +173,22 @@ defmodule Microwaveprop.RoverPlanning do end defp enqueue_paths_for(%Mission{} = mission) do + mission + |> desired_tuples() + |> insert_pending_paths_and_jobs(mission) + end + + # Cross product of (rover_location_id, station_id, band_mhz) for the + # mission's current scope. The reconcile + initial-create paths both + # go through this same source of truth. + @spec desired_tuples(Mission.t()) :: [{Ecto.UUID.t(), Ecto.UUID.t(), integer()}] + defp desired_tuples(%Mission{} = mission) do rovers = candidate_rover_locations(mission) stations = mission.stations || [] + bands = Mission.bands(mission) - pairs = - for rover <- rovers, station <- stations do - %{rover: rover, station: station} - end - - case pairs do - [] -> :ok - _ -> insert_pending_paths_and_jobs(mission, pairs) + for rover <- rovers, station <- stations, band <- bands do + {rover.id, station.id, band} end end @@ -171,34 +206,41 @@ defmodule Microwaveprop.RoverPlanning do Repo.all(from(l in Location, order_by: [desc: l.inserted_at])) end - defp insert_pending_paths_and_jobs(mission, pairs) do + defp insert_pending_paths_and_jobs([], _mission), do: :ok + + defp insert_pending_paths_and_jobs(tuples, mission) when is_list(tuples) do {:ok, _} = - pairs - |> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi -> + tuples + |> Enum.reduce(Multi.new(), fn {rover_id, station_id, band_mhz}, multi -> attrs = %{ mission_id: mission.id, - rover_location_id: r.id, - station_id: s.id, + rover_location_id: rover_id, + station_id: station_id, + band_mhz: band_mhz, status: :pending } - Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs), + Multi.insert( + multi, + {:path, rover_id, station_id, band_mhz}, + Path.changeset(%Path{}, attrs), on_conflict: :nothing, - conflict_target: [:mission_id, :rover_location_id, :station_id] + conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz] ) end) |> Repo.transaction() - enqueue_jobs(mission, pairs) + enqueue_jobs(tuples, mission) :ok end - defp enqueue_jobs(mission, pairs) do - for %{rover: r, station: s} <- pairs do + defp enqueue_jobs(tuples, mission) do + for {rover_id, station_id, band_mhz} <- tuples do %{ "mission_id" => mission.id, - "rover_location_id" => r.id, - "station_id" => s.id + "rover_location_id" => rover_id, + "station_id" => station_id, + "band_mhz" => band_mhz } |> RoverPathProfileWorker.new() |> Oban.insert() diff --git a/lib/microwaveprop/rover_planning/mission.ex b/lib/microwaveprop/rover_planning/mission.ex index a2c025b0..6d09c789 100644 --- a/lib/microwaveprop/rover_planning/mission.ex +++ b/lib/microwaveprop/rover_planning/mission.ex @@ -21,6 +21,7 @@ defmodule Microwaveprop.RoverPlanning.Mission do schema "rover_missions" do field :name, :string field :band_mhz, :integer, default: 10_000 + field :bands_mhz, {:array, :integer}, default: [] field :only_known_good, :boolean, default: true field :rover_height_ft, :float, default: 8.0 field :station_height_ft, :float, default: 30.0 @@ -38,26 +39,92 @@ defmodule Microwaveprop.RoverPlanning.Mission do @castable_fields [ :name, :band_mhz, + :bands_mhz, :only_known_good, :rover_height_ft, :station_height_ft, :notes ] + @doc """ + Returns the bands the mission's path matrix should iterate over. + Prefers `bands_mhz` (canonical, possibly empty) and falls back to + `[band_mhz]` for legacy rows. Always returns a non-empty list when + the mission is valid. + """ + @spec bands(t()) :: [integer()] + def bands(%__MODULE__{bands_mhz: list}) when is_list(list) and list != [], do: Enum.uniq(list) + + def bands(%__MODULE__{band_mhz: primary}) when is_integer(primary), do: [primary] + def bands(_), do: [] + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(mission, attrs) do mission - |> cast(attrs, @castable_fields) + |> cast(prepare_bands_attr(attrs), @castable_fields) + |> normalize_bands() |> cast_assoc(:stations, with: &Station.changeset/2, required: true) |> validate_required([:name, :band_mhz]) |> validate_length(:name, min: 1, max: 100) |> validate_number(:band_mhz, greater_than: 0) + |> validate_bands_mhz() |> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0) |> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0) |> validate_length(:notes, max: 4000) |> validate_at_least_one_station() end + # The multi-checkbox form sends a hidden empty-string sentinel so + # the key always exists even when nothing is checked. Drop those + # sentinels here so Ecto's `{:array, :integer}` cast doesn't choke. + defp prepare_bands_attr(%{"bands_mhz" => list} = attrs) when is_list(list) do + cleaned = + list + |> Enum.reject(fn v -> v == "" or v == nil end) + |> Enum.uniq() + + Map.put(attrs, "bands_mhz", cleaned) + end + + defp prepare_bands_attr(attrs), do: attrs + + # Treats `bands_mhz` as the canonical multi-band list. When the + # form submits it (multi-checkbox), use that. When only `band_mhz` + # is present (legacy single-band form path), seed bands_mhz from it. + # Also keeps `band_mhz` in sync as the "primary band" so older + # readers stay correct. + defp normalize_bands(changeset) do + bands = get_change(changeset, :bands_mhz) || get_field(changeset, :bands_mhz) + primary = get_change(changeset, :band_mhz) || get_field(changeset, :band_mhz) + + cond do + is_list(bands) and bands != [] -> + cleaned = bands |> Enum.uniq() |> Enum.sort() + + changeset + |> put_change(:bands_mhz, cleaned) + |> put_change(:band_mhz, hd(cleaned)) + + is_integer(primary) and primary > 0 -> + put_change(changeset, :bands_mhz, [primary]) + + true -> + changeset + end + end + + defp validate_bands_mhz(changeset) do + case get_field(changeset, :bands_mhz) do + list when is_list(list) and list != [] -> + if Enum.all?(list, &(is_integer(&1) and &1 > 0)), + do: changeset, + else: add_error(changeset, :bands_mhz, "must be a list of positive integers") + + _ -> + add_error(changeset, :bands_mhz, "must include at least one band") + end + end + defp validate_at_least_one_station(changeset) do case fetch_field(changeset, :stations) do {_source, list} when is_list(list) and list != [] -> diff --git a/lib/microwaveprop/rover_planning/path.ex b/lib/microwaveprop/rover_planning/path.ex index 7ee149d1..46e8e054 100644 --- a/lib/microwaveprop/rover_planning/path.ex +++ b/lib/microwaveprop/rover_planning/path.ex @@ -18,6 +18,7 @@ defmodule Microwaveprop.RoverPlanning.Path do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "rover_mission_paths" do + field :band_mhz, :integer field :status, Ecto.Enum, values: @statuses, default: :pending field :result, :map field :error, :string @@ -39,6 +40,7 @@ defmodule Microwaveprop.RoverPlanning.Path do def changeset(path, attrs) do path |> cast(attrs, [ + :band_mhz, :status, :result, :error, @@ -47,9 +49,10 @@ defmodule Microwaveprop.RoverPlanning.Path do :rover_location_id, :station_id ]) - |> validate_required([:mission_id, :rover_location_id, :station_id, :status]) + |> validate_required([:mission_id, :rover_location_id, :station_id, :band_mhz, :status]) |> validate_inclusion(:status, @statuses) - |> unique_constraint([:mission_id, :rover_location_id, :station_id], + |> validate_number(:band_mhz, greater_than: 0) + |> unique_constraint([:mission_id, :rover_location_id, :station_id, :band_mhz], name: :rover_mission_paths_unique ) end diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex index 8f1028cc..1a5433c5 100644 --- a/lib/microwaveprop/workers/rover_path_profile_worker.ex +++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex @@ -44,10 +44,8 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do end @impl Oban.Worker - def perform(%Oban.Job{ - args: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id} - }) do - case load_path(mission_id, rover_location_id, station_id) do + def perform(%Oban.Job{args: args}) do + case load_path(args) do nil -> :ok @@ -59,21 +57,33 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do end end - defp load_path(mission_id, rover_location_id, station_id) do + defp load_path( + %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id} = args + ) do + band_mhz = Map.get(args, "band_mhz") + Path |> where([p], p.mission_id == ^mission_id) |> where([p], p.rover_location_id == ^rover_location_id) |> where([p], p.station_id == ^station_id) + |> maybe_filter_band(band_mhz) |> preload([:rover_location, :station, :mission]) |> Repo.one() end + # Legacy queued jobs (pre-multi-band) didn't include band_mhz in + # their args. The single Path row that existed for that tuple is the + # only candidate, so loading without the band filter is unambiguous. + defp maybe_filter_band(query, nil), do: query + defp maybe_filter_band(query, band) when is_integer(band), do: where(query, [p], p.band_mhz == ^band) + defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do mark_status(path, :computing) + band_mhz = path.band_mhz || mission.band_mhz rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048 station_height_m = (mission.station_height_ft || 30.0) * 0.3048 - freq_ghz = mission.band_mhz / 1000 + freq_ghz = band_mhz / 1000 dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon) bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon) @@ -94,10 +104,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do midlat = (rover.lat + station.lat) / 2 midlon = (rover.lon + station.lon) / 2 - propagation_score = lookup_propagation_score(mission.band_mhz, midlat, midlon) + propagation_score = lookup_propagation_score(band_mhz, midlat, midlon) loss_budget = - compute_loss_budget(dist_km, mission.band_mhz, freq_ghz, analysis.diffraction_db) + compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db) store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget) diff --git a/lib/microwaveprop_web/live/rover_planning_live/form.ex b/lib/microwaveprop_web/live/rover_planning_live/form.ex index e1a85710..8c5496e4 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/form.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/form.ex @@ -181,6 +181,19 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true defp can_modify?(_, _), do: false + # Multi-checkbox values come from the form as a list of strings (or + # ints, depending on whether the changeset has cast them yet). Tolerate + # both, falling back to "not checked" for nil / empty bands lists. + defp band_checked?(values, target) when is_list(values) do + Enum.any?(values, fn + v when is_integer(v) -> v == target + v when is_binary(v) -> v == Integer.to_string(target) + _ -> false + end) + end + + defp band_checked?(_, _), do: false + @impl true def render(assigns) do ~H""" @@ -201,13 +214,41 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do > <.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" /> -
+ {Phoenix.HTML.html_escape(Enum.map_join(@form[:bands_mhz].errors, ", ", &elem(&1, 0)))} +
+