feat(rover-planning): worker pre-computes full /path output, PathLive renders cached

Three connected changes:

1) Extract PathLive.compute_path/4 + every helper it owned (resolve,
   profile grid lookup, sounding/ionosphere readouts, scoring, loss /
   power budgets) into Microwaveprop.Propagation.PathCompute. PathLive
   now delegates; ~410 lines of dead helpers deleted from PathLive.

2) RoverPathProfileWorker calls PathCompute.compute/4 with the
   mission's heights and PathLive's default station params (10 W TX,
   30 dBi gains). Stores the full atom-keyed compute output as a
   Base64-encoded :erlang.term_to_binary/1 blob alongside the flat
   summary fields the rover-planning show table reads. The blob
   roundtrips structs and DateTime exactly (Jason.encode would lose
   them).

3) PathLive accepts ?rover_path_id=UUID. When set, loads the cached
   Path, decodes the term (binary_to_term :safe), assigns it as
   @result, and renders normally — no compute_path call. The
   rover-planning show table now links rows directly to
   /path?rover_path_id=UUID, so a click opens the full Path
   Calculator UI from cached data without re-running terrain / HRRR /
   sounding lookups.

Bonus prod fixes folded in:
- PathShow's elevation chart attribute (data-* → data-profile JSON)
  was crashing the JS hook with 'unexpected character at line 1'.
- Station.changeset now wipes previously-resolved callsign/grid/
  lat/lon when the user types into :input — typing 'AA5' early
  resolved to a wrong location and locked it; subsequent keystrokes
  never re-resolved.
- phx-debounce=600 on the station input so QRZ doesn't get hit on
  every keystroke.
This commit is contained in:
Graham McIntire 2026-05-03 14:58:16 -05:00
parent 2327fabe29
commit a4f0e171e8
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 721 additions and 557 deletions

View file

@ -0,0 +1,447 @@
defmodule Microwaveprop.Propagation.PathCompute do
@moduledoc """
Pure(ish) path-calculator engine extracted from `MicrowavepropWeb.PathLive`.
Given a source / destination / band / station-params tuple, runs the
same pipeline `/path` does: terrain analysis (ITU-R P.526), HRRR
profile lookup at 9 evenly-spaced points, sounding & ionosphere
readouts, native HRRR duct info, composite scoring, loss + power
budgets, and the 18-hour propagation forecast at the path midpoint.
Used by both:
* the live `MicrowavepropWeb.PathLive` page (live recompute)
* the rover-planning `RoverPathProfileWorker` (background cache
result map is stored on the matching `RoverPlanning.Path`).
"""
import Microwaveprop.Geo, only: [haversine_km: 4, bearing_deg: 4]
alias Microwaveprop.Geo
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Station
require Logger
@type station_params :: %{
required(:src_height_m) => float(),
required(:dst_height_m) => float(),
required(:tx_power_dbm) => float(),
required(:src_gain_dbi) => float(),
required(:dst_gain_dbi) => float(),
optional(:src_height_ft) => float(),
optional(:dst_height_ft) => float(),
optional(:tx_power_mw) => float()
}
@type result :: %{
source: map(),
destination: map(),
station_params: station_params(),
band_config: map(),
band_mhz: integer(),
freq_ghz: float(),
dist_km: float(),
bearing: float(),
terrain: map() | nil,
conditions: map() | nil,
scoring: map() | nil,
loss_budget: map(),
power_budget: map(),
forecast: list(),
hrrr_count: integer(),
hrrr_points: list(),
ionosphere: map() | nil,
sounding: map() | nil
}
@doc """
Run the full path-calculator pipeline. Returns `{:ok, result}` on
success, `{:error, reason}` when either endpoint fails to resolve
(callsign / grid / coords).
"""
@spec compute(String.t(), String.t(), integer(), station_params()) ::
{:ok, result()} | {:error, term()}
def compute(source, dest, band_mhz, station_params) do
with {:ok, src} <- resolve_location(source),
{:ok, dst} <- resolve_location(dest) do
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
freq_ghz = band_mhz / 1000
dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)
terrain_result = compute_terrain(src, dst, dist_km, freq_ghz, station_params)
now = DateTime.utc_now()
midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2
profile_valid_time = latest_profile_valid_time(now)
profile_grid = profile_grid_for(profile_valid_time)
sample_points = path_sample_points(src, dst, 9)
{grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time)
fallback_hits = fallback_hits(misses, now)
hrrr_points = Enum.reverse(grid_hits, fallback_hits)
hrrr_profiles = Enum.map(hrrr_points, & &1.profile)
sounding = build_sounding_readout(midlat, midlon, now)
native_duct =
case Weather.nearest_native_duct_info(midlat, midlon, now) do
{:ok, info} -> info
{:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil}
end
{conditions, scoring} =
build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
power_budget = compute_power_budget(station_params, loss_budget)
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km)
{:ok,
%{
source: src,
destination: dst,
station_params: station_params,
band_config: band_config,
band_mhz: band_mhz,
freq_ghz: freq_ghz,
dist_km: dist_km,
bearing: bearing,
terrain: terrain_result,
conditions: conditions,
scoring: scoring,
loss_budget: loss_budget,
power_budget: power_budget,
forecast: forecast,
hrrr_count: length(hrrr_profiles),
hrrr_points: hrrr_points,
ionosphere: ionosphere,
sounding: sounding
}}
end
end
defp compute_terrain(src, dst, dist_km, freq_ghz, station_params) do
case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
{:ok, profile} ->
analysis =
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
ant_ht_a: station_params.src_height_m,
ant_ht_b: station_params.dst_height_m
)
%{profile: profile, analysis: analysis}
{:error, reason} ->
Logger.warning(
"PathCompute terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}"
)
nil
end
end
defp fallback_hits(misses, now) do
misses
|> Enum.reverse()
|> Task.async_stream(&fallback_hrrr_point(&1, now),
max_concurrency: 4,
timeout: 5_000,
on_timeout: :kill_task
)
|> Enum.zip(Enum.reverse(misses))
|> Enum.flat_map(fn
{{:ok, nil}, _} ->
[]
{{:ok, point}, _} ->
[point]
{{:exit, reason}, {label, lat, lon}} ->
Logger.error(
"PathCompute HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}"
)
[]
end)
end
@doc "Public for PathLive's `path_forecast_detail` event."
@spec resolve_location(String.t()) :: {:ok, map()} | {:error, String.t()}
def resolve_location(input) do
case MicrowavepropWeb.LocationResolver.resolve(input) do
:empty -> {:error, "Location is required"}
other -> other
end
end
@doc "Public for PathLive's `path_forecast_detail` event."
@spec build_ionosphere_readout(integer(), float(), float(), float()) :: map() | nil
def build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
case Ionosphere.nearest_foes(midlat, midlon) do
{:ok, obs} ->
es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)
muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km)
%{
station_code: obs.station_code,
valid_time: obs.valid_time,
fo_es_mhz: obs.fo_es_mhz,
fo_f2_mhz: obs.fo_f2_mhz,
mufd_mhz: obs.mufd_mhz,
es_score: es_score,
es_muf_mhz: muf,
es_in_range?: dist_km >= 500 and dist_km <= 2500
}
{:error, _reason} ->
nil
end
end
def build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil
defp profile_grid_for(nil), do: nil
defp profile_grid_for(valid_time) do
case ProfilesFile.read(valid_time) do
{:ok, grid} ->
grid
{:error, reason} ->
Logger.warning(
"PathCompute ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}"
)
nil
other ->
Logger.warning(
"PathCompute ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}"
)
nil
end
end
defp lookup_profile_grid(grid, lat, lon) do
{snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon)
Map.get(grid, {snapped_lat, snapped_lon})
end
defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do
Enum.reduce(sample_points, {[], []}, fn pt, acc ->
classify_grid_hit(pt, profile_grid, profile_valid_time, acc)
end)
end
defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]}
defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do
case lookup_profile_grid(grid, lat, lon) do
nil -> {hits, [pt | mss]}
cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss}
end
end
defp fallback_hrrr_point({label, lat, lon}, now) do
case Weather.find_nearest_hrrr(lat, lon, now) do
nil -> nil
profile -> %{label: label, profile: profile}
end
end
defp latest_profile_valid_time(now) do
case ProfilesFile.list_valid_times() do
[] -> nil
times -> pick_latest_at_or_before(times, now)
end
end
defp pick_latest_at_or_before(times, now) do
case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do
[] -> List.first(times)
past -> Enum.max(past, DateTime)
end
end
defp profile_from_cell(cell, lat, lon, valid_time) do
cell
|> Map.put_new(:lat, lat)
|> Map.put_new(:lon, lon)
|> Map.put_new(:valid_time, valid_time)
|> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient))
|> Map.put_new(:surface_refractivity, nil)
|> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0)
|> Map.put_new(:duct_characteristics, nil)
end
defp path_sample_points(src, dst, count) when count >= 2 do
Enum.map(1..count, fn i ->
t = (i - 1) / (count - 1)
lat = src.lat + (dst.lat - src.lat) * t
lon = src.lon + (dst.lon - src.lon) * t
label =
cond do
i == 1 -> "Source"
i == count -> "Destination"
i * 2 == count + 1 -> "Midpoint"
true -> "#{round(t * 100)}%"
end
{label, lat, lon}
end)
end
defp build_sounding_readout(midlat, midlon, now) do
case Weather.nearest_sounding_to(midlat, midlon, now) do
{:ok, sounding} ->
station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station
station = station || Repo.get(Station, sounding.station_id)
distance_km =
if station, do: haversine_km(midlat, midlon, station.lat, station.lon)
%{
station_code: station && station.station_code,
station_name: station && station.name,
observed_at: sounding.observed_at,
ducting_detected: sounding.ducting_detected,
min_refractivity_gradient: sounding.min_refractivity_gradient,
boundary_layer_depth_m: sounding.boundary_layer_depth_m,
precipitable_water_mm: sounding.precipitable_water_mm,
distance_km: distance_km
}
{:error, :not_found} ->
nil
end
end
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1)
dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1)
if temps == [] or dewpoints == [] do
{nil, nil}
else
avg_temp_c = Enum.sum(temps) / length(temps)
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)
conditions = %{
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
temp_f: Scorer.c_to_f(avg_temp_c),
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
temp_c: avg_temp_c,
dewpoint_c: avg_dewpoint_c,
wind_speed_kts: nil,
sky_cover_pct: nil,
utc_hour: now.hour,
utc_minute: now.minute,
month: now.month,
latitude: (src.lat + dst.lat) / 2,
longitude: src.lon,
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
bulk_richardson: native_duct[:bulk_richardson]
}
scoring = Scorer.composite_score(conditions, band_config)
{conditions, scoring}
end
end
defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
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 * dist_km
h2o_coeff = band_config.h2o_coeff
abs_humidity =
if conditions do
conditions.abs_humidity
else
7.5
end
h2o_loss = h2o_coeff * abs_humidity * dist_km
rain_loss =
if conditions && conditions.rain_rate_mmhr > 0 do
gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
gamma * dist_km
else
0.0
end
diffraction_loss =
if terrain_result do
terrain_result.analysis.diffraction_db * 1.0
else
0.0
end
total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss
%{
fspl: Float.round(fspl, 1),
o2: Float.round(o2_loss, 2),
h2o: Float.round(h2o_loss, 2),
rain: Float.round(rain_loss, 2),
diffraction: Float.round(diffraction_loss, 1),
total: Float.round(total, 1)
}
end
defp compute_power_budget(station_params, loss_budget) do
tx_power_dbm = station_params.tx_power_dbm
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi
rx_sensitivity_cw = -140.0
rx_sensitivity_ssb = -130.0
margin_cw = rx_power_dbm - rx_sensitivity_cw
margin_ssb = rx_power_dbm - rx_sensitivity_ssb
%{
tx_power_dbm: Float.round(tx_power_dbm, 1),
eirp_dbm: Float.round(eirp_dbm, 1),
rx_power_dbm: Float.round(rx_power_dbm, 1),
margin_cw: Float.round(margin_cw, 1),
margin_ssb: Float.round(margin_ssb, 1)
}
end
# Avoid unused-alias warnings for the import.
@compile {:no_warn_undefined, Geo}
end

View file

@ -41,21 +41,45 @@ defmodule Microwaveprop.RoverPlanning.Station do
end end
# When `input` looks like a callsign or grid, geocode it and populate # When `input` looks like a callsign or grid, geocode it and populate
# the matching field. Skips resolution when the user already provided # the matching field. If the user TYPED a new input (changeset has a
# explicit lat/lon — they may want a free-form coordinate that doesn't # `:input` change), wipe any previously-resolved callsign / grid /
# round-trip through callsign lookup. # lat / lon first — otherwise an early prefix that resolved
# successfully (e.g. "AA5" matched a callsign) would lock those
# fields and the user's later keystrokes would never re-resolve.
defp resolve_input(changeset) do defp resolve_input(changeset) do
cond do cond do
not changeset.valid? -> not changeset.valid? ->
changeset changeset
input_changed?(changeset) ->
changeset
|> clear_resolved()
|> resolve_from_input()
get_field(changeset, :lat) && get_field(changeset, :lon) -> get_field(changeset, :lat) && get_field(changeset, :lon) ->
changeset changeset
input = get_field(changeset, :input) -> true ->
resolve_from_input(changeset)
end
end
defp input_changed?(changeset), do: not is_nil(get_change(changeset, :input))
defp clear_resolved(changeset) do
changeset
|> put_change(:callsign, nil)
|> put_change(:grid, nil)
|> put_change(:lat, nil)
|> put_change(:lon, nil)
end
defp resolve_from_input(changeset) do
case get_field(changeset, :input) do
input when is_binary(input) and input != "" ->
apply_resolution(changeset, LocationResolver.resolve(String.trim(input))) apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
true -> _ ->
changeset changeset
end end
end end

View file

@ -1,15 +1,19 @@
defmodule Microwaveprop.Workers.RoverPathProfileWorker do defmodule Microwaveprop.Workers.RoverPathProfileWorker do
@moduledoc """ @moduledoc """
Computes a terrain path profile for one rover-location station Pre-computes one rover-planning path so the live `/path` page can
pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous render instantly when the operator clicks through. Calls the same
`MicrowavepropWeb.PathLive` compute pipeline (elevation profile + `Microwaveprop.Propagation.PathCompute.compute/4` pipeline `/path`
ITU-R P.526 analysis at the mission's band + heights) and stores uses (terrain analysis, 9 HRRR profile samples, sounding +
the result as JSON on the matching `RoverPlanning.Path`. ionosphere readouts, scoring, link + power budgets, forecast) and
stores the full output on the matching `RoverPlanning.Path`.
The HRRR-along-path step from `/path` is intentionally skipped here: The map is persisted in two parallel forms:
it depends on real-time profile grids that aren't worth pinning to a * Flat string-keyed fields (`distance_km`, `verdict`,
stored mission result. The rendered show page can re-fetch live `propagation_score`, `total_baseline_loss_db`, `path_points`,
weather on demand if needed. ) what the rover-planning show table reads at-a-glance.
* `term` the entire atom-keyed compute output, encoded with
`:erlang.term_to_binary/1` + Base64. PathLive decodes this when
handed `?rover_path_id=UUID` and renders without recomputing.
""" """
use Oban.Worker, use Oban.Worker,
queue: :terrain, queue: :terrain,
@ -22,22 +26,13 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
import Ecto.Query import Ecto.Query
alias Microwaveprop.Geo alias Microwaveprop.Propagation.PathCompute
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.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)
@ -73,9 +68,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|> Repo.one() |> Repo.one()
end end
# Legacy / malformed args (no band_mhz, or wrong types). Don't crash
# the queue — log enough context to debug and drop the job. New
# enqueues from `enqueue_paths_for/1` always include band_mhz.
defp load_path(args) do defp load_path(args) do
Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}") Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}")
@ -86,38 +78,33 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
mark_status(path, :computing) mark_status(path, :computing)
band_mhz = path.band_mhz || mission.band_mhz band_mhz = path.band_mhz || mission.band_mhz
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048 src_height_ft = mission.rover_height_ft || 8.0
station_height_m = (mission.station_height_ft || 30.0) * 0.3048 dst_height_ft = mission.station_height_ft || 30.0
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)
case ElevationClient.fetch_elevation_profile( station_params = %{
rover.lat, src_height_ft: src_height_ft,
rover.lon, dst_height_ft: dst_height_ft,
station.lat, src_height_m: src_height_ft * 0.3048,
station.lon, dst_height_m: dst_height_ft * 0.3048,
64, # Defaults match PathLive's form fallback (10 W TX, 30 dBi gains
download: true # at each end). Operators can override on the live `/path` page;
) do # the cached snapshot uses these so power-budget numbers are
{:ok, profile} -> # representative for a typical microwave home station.
analysis = tx_power_dbm: 20.0,
TerrainAnalysis.analyse(profile, dist_km, freq_ghz, tx_power_mw: :math.pow(10, 20.0 / 10),
ant_ht_a: rover_height_m, src_gain_dbi: 30.0,
ant_ht_b: station_height_m dst_gain_dbi: 30.0
) }
midlat = (rover.lat + station.lat) / 2 src = "#{rover.lat},#{rover.lon}"
midlon = (rover.lon + station.lon) / 2 dst = "#{station.lat},#{station.lon}"
propagation_score = lookup_propagation_score(band_mhz, midlat, midlon)
loss_budget = case PathCompute.compute(src, dst, band_mhz, station_params) do
compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db) {:ok, result} ->
store_complete(path, result, band_mhz, rover, station)
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, "compute failed: #{inspect(reason)}")
end end
rescue rescue
e -> e ->
@ -126,47 +113,15 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
reraise e, __STACKTRACE__ reraise e, __STACKTRACE__
end end
defp store_complete(path, _profile, analysis, dist_km, bearing, propagation_score, loss_budget) do defp store_complete(path, result, band_mhz, rover, station) do
# Store the FULL per-point analysis (beam height, Fresnel-zone radius, flat = flat_summary(result, band_mhz, rover, station)
# clearance) so the elevation-profile chart on the cached path-detail
# page can render the same way `/path` does — line-of-sight + Fresnel
# overlay — without recomputing TerrainAnalysis on click.
points =
Enum.map(analysis.points, fn p ->
%{
"lat" => p.lat,
"lon" => p.lon,
"d" => p.d,
"dist_km" => p.dist_km,
"elev" => p.elev,
"beam" => Map.get(p, :beam, 0.0),
"r1" => Map.get(p, :r1, 0.0),
"clearance" => Map.get(p, :clearance, 0.0)
}
end)
result = %{ full_term = result |> :erlang.term_to_binary() |> Base.encode64()
"distance_km" => dist_km,
"bearing_deg" => bearing,
"max_elevation_m" => analysis.max_elevation_m,
"min_clearance_m" => analysis.min_clearance_m,
"diffraction_db" => analysis.diffraction_db,
"fresnel_hit_count" => analysis.fresnel_hit_count,
"obstructed_count" => analysis.obstructed_count,
"verdict" => to_string(analysis.verdict),
"sample_count" => length(points),
"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 path
|> Path.changeset(%{ |> Path.changeset(%{
status: :complete, status: :complete,
result: result, result: Map.put(flat, "term", full_term),
error: nil, error: nil,
computed_at: DateTime.truncate(DateTime.utc_now(), :second) computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}) })
@ -181,6 +136,76 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
end end
end end
# The handful of fields the rover-planning show table renders inline
# (distance, verdict, propagation score, total baseline loss). Lifted
# out of the full compute output so the table can read them directly
# without round-tripping the term blob.
defp flat_summary(result, band_mhz, rover, station) do
analysis = result.terrain && result.terrain.analysis
midlat = (rover.lat + station.lat) / 2
midlon = (rover.lon + station.lon) / 2
result
|> geometry_summary(analysis)
|> Map.merge(loss_summary(result.loss_budget))
|> Map.put("propagation_score", lookup_propagation_score(band_mhz, midlat, midlon))
|> Map.put("path_points", path_points(analysis))
end
defp geometry_summary(result, nil) do
%{
"distance_km" => result.dist_km,
"bearing_deg" => result.bearing,
"max_elevation_m" => nil,
"min_clearance_m" => nil,
"diffraction_db" => 0.0,
"fresnel_hit_count" => 0,
"obstructed_count" => 0,
"verdict" => nil,
"sample_count" => nil
}
end
defp geometry_summary(result, analysis) do
%{
"distance_km" => result.dist_km,
"bearing_deg" => result.bearing,
"max_elevation_m" => analysis.max_elevation_m,
"min_clearance_m" => analysis.min_clearance_m,
"diffraction_db" => analysis.diffraction_db,
"fresnel_hit_count" => analysis.fresnel_hit_count,
"obstructed_count" => analysis.obstructed_count,
"verdict" => to_string(analysis.verdict),
"sample_count" => length(analysis.points)
}
end
defp loss_summary(loss) do
%{
"free_space_loss_db" => loss.fspl,
"oxygen_loss_db" => loss.o2,
"humidity_loss_db_baseline" => loss.h2o,
"total_baseline_loss_db" => loss.total
}
end
defp path_points(nil), do: []
defp path_points(analysis) do
Enum.map(analysis.points, fn p ->
%{
"lat" => p.lat,
"lon" => p.lon,
"d" => p.d,
"dist_km" => p.dist_km,
"elev" => p.elev,
"beam" => Map.get(p, :beam, 0.0),
"r1" => Map.get(p, :r1, 0.0),
"clearance" => Map.get(p, :clearance, 0.0)
}
end)
end
defp store_failed(path, message) do defp store_failed(path, message) do
path path
|> Path.changeset(%{ |> Path.changeset(%{
@ -209,35 +234,6 @@ 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
# path at the most recent forecast time available on disk. Returns
# nil when no scores file exists for this band yet — callers (the
# show page, primarily) render that as "—" without distinguishing
# "no data" from "score really is 0".
defp lookup_propagation_score(band_mhz, lat, lon) do defp lookup_propagation_score(band_mhz, lat, lon) do
case ScoresFile.list_valid_times(band_mhz) do case ScoresFile.list_valid_times(band_mhz) do
[] -> [] ->

View file

@ -8,17 +8,12 @@ defmodule MicrowavepropWeb.PathLive do
alias Microwaveprop.Buildings.Index, as: BuildingsIndex alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
alias Microwaveprop.Canopy alias Microwaveprop.Canopy
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ProfilesFile alias Microwaveprop.Propagation.PathCompute
alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.RoverPlanning.Path, as: RoverPath
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Station
require Logger require Logger
@ -63,6 +58,47 @@ defmodule MicrowavepropWeb.PathLive do
end end
@impl true @impl true
def handle_params(%{"rover_path_id" => rover_path_id} = _params, _uri, socket)
when is_binary(rover_path_id) and rover_path_id != "" do
case load_cached_path(rover_path_id) do
{:ok, path, result} ->
params = params_from_cached(path, result)
socket =
assign(socket,
source: params["source"],
destination: params["destination"],
band: params["band"],
src_height_ft: params["src_height_ft"],
dst_height_ft: params["dst_height_ft"],
tx_power_dbm: params["tx_power_dbm"],
src_gain_dbi: params["src_gain_dbi"],
dst_gain_dbi: params["dst_gain_dbi"],
source_is_gps: false,
result: result,
computing: false,
error: nil
)
{:noreply, socket}
{:error, :not_found} ->
{:noreply,
socket
|> put_flash(:error, "Cached path profile not found.")
|> push_navigate(to: ~p"/path")}
{:error, :stale_term} ->
# Cached blob can't be deserialized (e.g. old enrichment from
# before this code shipped). Fall through to live recompute
# using the path's stored source/dest/band.
case load_cached_path_for_redirect(rover_path_id) do
{:ok, params} -> {:noreply, push_patch(socket, to: ~p"/path?#{params}", replace: true)}
{:error, _} -> {:noreply, push_navigate(socket, to: ~p"/path")}
end
end
end
def handle_params(params, _uri, socket) do def handle_params(params, _uri, socket) do
p = Map.merge(@defaults, Map.take(params, @url_params)) p = Map.merge(@defaults, Map.take(params, @url_params))
@ -94,6 +130,82 @@ defmodule MicrowavepropWeb.PathLive do
end end
end end
# Loads a cached rover-planning Path and rebuilds the @result map
# PathLive's render expects. The blob lives under `result["term"]`
# (Base64-encoded `:erlang.term_to_binary/1` of PathCompute's full
# output). `:safe` mode rejects unknown atoms — every atom we
# roundtrip is one this codebase already references at compile time,
# so it's known.
defp load_cached_path(rover_path_id) do
with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id),
%RoverPath{result: %{"term" => term}} <- Repo.get(RoverPath, uuid),
{:ok, decoded} <- decode_term(term) do
{:ok, nil, decoded}
else
%RoverPath{} -> {:error, :stale_term}
nil -> {:error, :not_found}
:error -> {:error, :not_found}
{:error, _} = err -> err
end
end
# Fall-back: when the cached term is missing/stale, redirect to
# /path with the source/dest/band fields filled in from the Path's
# rover_location + station so the user gets a live recompute.
defp load_cached_path_for_redirect(rover_path_id) do
with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id),
%RoverPath{} = path <-
RoverPath |> Repo.get(uuid) |> Repo.preload([:rover_location, :station, :mission]) do
{:ok,
%{
"source" => Maidenhead.from_latlon(path.rover_location.lat, path.rover_location.lon, 10),
"destination" =>
path.station.callsign || path.station.grid ||
"#{path.station.lat},#{path.station.lon}",
"band" => Integer.to_string(path.band_mhz || path.mission.band_mhz),
"src_height_ft" => Float.to_string((path.mission.rover_height_ft || 8.0) * 1.0),
"dst_height_ft" => Float.to_string((path.mission.station_height_ft || 30.0) * 1.0)
}}
else
_ -> {:error, :not_found}
end
end
defp decode_term(term) when is_binary(term) do
with {:ok, binary} <- Base.decode64(term),
decoded when is_map(decoded) <- safe_binary_to_term(binary) do
{:ok, decoded}
else
_ -> {:error, :stale_term}
end
rescue
_ -> {:error, :stale_term}
end
defp safe_binary_to_term(binary) do
:erlang.binary_to_term(binary, [:safe])
rescue
_ -> nil
end
defp params_from_cached(_path, result) do
sp = result.station_params || %{}
%{
"source" => label_for(result.source),
"destination" => label_for(result.destination),
"band" => Integer.to_string(result.band_mhz),
"src_height_ft" => Float.to_string((Map.get(sp, :src_height_ft) || 30.0) * 1.0),
"dst_height_ft" => Float.to_string((Map.get(sp, :dst_height_ft) || 30.0) * 1.0),
"tx_power_dbm" => Float.to_string((Map.get(sp, :tx_power_dbm) || 20.0) * 1.0),
"src_gain_dbi" => Float.to_string((Map.get(sp, :src_gain_dbi) || 30.0) * 1.0),
"dst_gain_dbi" => Float.to_string((Map.get(sp, :dst_gain_dbi) || 30.0) * 1.0)
}
end
defp label_for(%{label: label}) when is_binary(label), do: label
defp label_for(_), do: ""
defp auto_calculate(socket, p) do defp auto_calculate(socket, p) do
if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
not socket.assigns.computing and is_nil(socket.assigns.result) do not socket.assigns.computing and is_nil(socket.assigns.result) do
@ -263,420 +375,9 @@ defmodule MicrowavepropWeb.PathLive do
end end
defp compute_path(source, dest, band_mhz, station_params) do defp compute_path(source, dest, band_mhz, station_params) do
with {:ok, src} <- resolve_location(source), PathCompute.compute(source, dest, band_mhz, station_params)
{:ok, dst} <- resolve_location(dest) do
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
freq_ghz = band_mhz / 1000
dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)
# Terrain profile with antenna heights
terrain_result =
case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
{:ok, profile} ->
analysis =
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
ant_ht_a: station_params.src_height_m,
ant_ht_b: station_params.dst_height_m
)
%{profile: profile, analysis: analysis}
{:error, reason} ->
Logger.warning(
"PathLive terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}"
)
nil
end
# HRRR profiles along path — 9 evenly-spaced samples so mid-path
# ducts that endpoint-only queries miss show up in the duct count.
# Source preference: the on-disk grid profile store (full-CONUS
# coverage from the hourly chain) is checked first; the per-QSO
# `hrrr_profiles` DB table is only the fallback for points the
# current chain hasn't published yet.
now = DateTime.utc_now()
midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2
profile_valid_time = latest_profile_valid_time(now)
# Load the full profile grid ONCE (single NFS read + gunzip + decode)
# instead of letting 9 async_stream workers each re-decode the same
# ~30 MB file. Each per-point lookup becomes a Map.get.
profile_grid = profile_grid_for(profile_valid_time)
sample_points = path_sample_points(src, dst, 9)
# Fast path: in-memory Map.get against the single decoded grid.
# Only points that miss the grid (e.g. just-rolled-over chain) take
# the slow DB-fallback path concurrently below.
{grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time)
fallback_hits =
misses
|> Enum.reverse()
|> Task.async_stream(&fallback_hrrr_point(&1, now), max_concurrency: 4, timeout: 5_000, on_timeout: :kill_task)
|> Enum.zip(Enum.reverse(misses))
|> Enum.flat_map(fn
{{:ok, nil}, _} ->
[]
{{:ok, point}, _} ->
[point]
{{:exit, reason}, {label, lat, lon}} ->
Logger.error(
"PathLive HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}"
)
[]
end)
hrrr_points = Enum.reverse(grid_hits, fallback_hits)
hrrr_profiles = Enum.map(hrrr_points, & &1.profile)
# Independent duct signal: the nearest RAOB within 300 km / ±3 h of
# the midpoint. HRRR pressure levels systematically under-read thin
# surface ducts that sounding data resolves.
sounding = build_sounding_readout(midlat, midlon, now)
# Native-resolution HRRR duct band + Bulk Richardson near the
# midpoint. Resolves thin trapping layers HRRR's 13 pressure levels
# miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on
# Richardson so turbulent duct readings don't inflate the score.
#
# `:not_found` is the documented fallback when no native sample is
# within ±0.07° / ±1 h of the midpoint — the native profile table
# is sparsely populated (only points the per-QSO worker has
# touched). The empty-map fallback is correct behavior, not a
# failure, so we don't log it.
native_duct =
case Weather.nearest_native_duct_info(midlat, midlon, now) do
{:ok, info} -> info
{:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil}
end
# Build conditions and score
{conditions, scoring} =
build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)
# Loss and power budgets
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
power_budget = compute_power_budget(station_params, loss_budget)
# 18-hour forecast from propagation grid (midpoint of path)
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
# Ionosphere readout at the midpoint (sporadic-E potential).
# Only relevant for VHF where Es propagation is physically possible.
ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km)
{:ok,
%{
source: src,
destination: dst,
station_params: station_params,
band_config: band_config,
band_mhz: band_mhz,
freq_ghz: freq_ghz,
dist_km: dist_km,
bearing: bearing,
terrain: terrain_result,
conditions: conditions,
scoring: scoring,
loss_budget: loss_budget,
power_budget: power_budget,
forecast: forecast,
hrrr_count: length(hrrr_profiles),
hrrr_points: hrrr_points,
ionosphere: ionosphere,
sounding: sounding
}}
end
end end
# Returns nil when there's no usable readout, or a map describing the
# nearest ionosonde's current foEs + the computed Es score for
# (band, distance). Only VHF bands get the readout.
defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
case Ionosphere.nearest_foes(midlat, midlon) do
{:ok, obs} ->
es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)
muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km)
%{
station_code: obs.station_code,
valid_time: obs.valid_time,
fo_es_mhz: obs.fo_es_mhz,
fo_f2_mhz: obs.fo_f2_mhz,
mufd_mhz: obs.mufd_mhz,
es_score: es_score,
es_muf_mhz: muf,
es_in_range?: dist_km >= 500 and dist_km <= 2500
}
{:error, _reason} ->
nil
end
end
defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil
defp resolve_location(input) do
case MicrowavepropWeb.LocationResolver.resolve(input) do
:empty -> {:error, "Location is required"}
other -> other
end
end
# The grid is the single decoded output of ProfilesFile.read/1 — keys are
# `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2.
# We mirror the same snapping locally instead of round-tripping through
# ProfilesFile.read_point/3 (which would re-fetch the cached grid).
defp profile_grid_for(nil), do: nil
defp profile_grid_for(valid_time) do
case ProfilesFile.read(valid_time) do
{:ok, grid} ->
grid
{:error, reason} ->
Logger.warning("PathLive ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}")
nil
other ->
Logger.warning("PathLive ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}")
nil
end
end
defp lookup_profile_grid(grid, lat, lon) do
{snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon)
Map.get(grid, {snapped_lat, snapped_lon})
end
defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do
Enum.reduce(sample_points, {[], []}, fn pt, acc ->
classify_grid_hit(pt, profile_grid, profile_valid_time, acc)
end)
end
defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]}
defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do
case lookup_profile_grid(grid, lat, lon) do
nil -> {hits, [pt | mss]}
cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss}
end
end
defp fallback_hrrr_point({label, lat, lon}, now) do
case Weather.find_nearest_hrrr(lat, lon, now) do
nil -> nil
profile -> %{label: label, profile: profile}
end
end
# The most recent on-disk profile file at or before `now`. Falls back
# to the earliest available file if everything cached is in the future
# (cold-start state during a missed chain). Returns `nil` when the
# store is empty — caller then drops to the DB-table fallback.
defp latest_profile_valid_time(now) do
case ProfilesFile.list_valid_times() do
[] -> nil
times -> pick_latest_at_or_before(times, now)
end
end
defp pick_latest_at_or_before(times, now) do
case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do
[] -> List.first(times)
past -> Enum.max(past, DateTime)
end
end
# ProfilesFile cells are already flat HrrrProfile-shaped maps — the
# `:profile` key holds the vertical pressure-level list, NOT a wrapper
# around the surface fields. Stamp `lat`/`lon` (from the sample point,
# since cells don't carry their own coordinates — they're the map key)
# and `valid_time` so downstream template + scoring code is shape-agnostic.
defp profile_from_cell(cell, lat, lon, valid_time) do
cell
|> Map.put_new(:lat, lat)
|> Map.put_new(:lon, lon)
|> Map.put_new(:valid_time, valid_time)
|> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient))
|> Map.put_new(:surface_refractivity, nil)
|> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0)
|> Map.put_new(:duct_characteristics, nil)
end
# Linear interpolation along the great-circle-approximate path. Good
# enough at the path-calculator's typical range (<2000 km); for longer
# paths the path deviates from a rhumb line but we're picking
# HRRR grid cells, not doing precise bearing.
defp path_sample_points(src, dst, count) when count >= 2 do
Enum.map(1..count, fn i ->
t = (i - 1) / (count - 1)
lat = src.lat + (dst.lat - src.lat) * t
lon = src.lon + (dst.lon - src.lon) * t
label =
cond do
i == 1 -> "Source"
i == count -> "Destination"
i * 2 == count + 1 -> "Midpoint"
true -> "#{round(t * 100)}%"
end
{label, lat, lon}
end)
end
defp build_sounding_readout(midlat, midlon, now) do
case Weather.nearest_sounding_to(midlat, midlon, now) do
{:ok, sounding} ->
station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station
station = station || Repo.get(Station, sounding.station_id)
distance_km =
if station, do: haversine_km(midlat, midlon, station.lat, station.lon)
%{
station_code: station && station.station_code,
station_name: station && station.name,
observed_at: sounding.observed_at,
ducting_detected: sounding.ducting_detected,
min_refractivity_gradient: sounding.min_refractivity_gradient,
boundary_layer_depth_m: sounding.boundary_layer_depth_m,
precipitable_water_mm: sounding.precipitable_water_mm,
distance_km: distance_km
}
{:error, :not_found} ->
nil
end
end
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1)
dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1)
if temps == [] or dewpoints == [] do
{nil, nil}
else
avg_temp_c = Enum.sum(temps) / length(temps)
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)
conditions = %{
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
temp_f: Scorer.c_to_f(avg_temp_c),
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
temp_c: avg_temp_c,
dewpoint_c: avg_dewpoint_c,
wind_speed_kts: nil,
sky_cover_pct: nil,
utc_hour: now.hour,
utc_minute: now.minute,
month: now.month,
latitude: (src.lat + dst.lat) / 2,
longitude: src.lon,
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
bulk_richardson: native_duct[:bulk_richardson]
}
scoring = Scorer.composite_score(conditions, band_config)
{conditions, scoring}
end
end
defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
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 * dist_km
h2o_coeff = band_config.h2o_coeff
abs_humidity =
if conditions do
conditions.abs_humidity
else
7.5
end
h2o_loss = h2o_coeff * abs_humidity * dist_km
rain_loss =
if conditions && conditions.rain_rate_mmhr > 0 do
gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
gamma * dist_km
else
0.0
end
diffraction_loss =
if terrain_result do
# TerrainAnalysis.deygout_diffraction returns integer 0 for clear
# paths; coerce to float so Float.round/2 below is happy.
terrain_result.analysis.diffraction_db * 1.0
else
0.0
end
total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss
%{
fspl: Float.round(fspl, 1),
o2: Float.round(o2_loss, 2),
h2o: Float.round(h2o_loss, 2),
rain: Float.round(rain_loss, 2),
diffraction: Float.round(diffraction_loss, 1),
total: Float.round(total, 1)
}
end
defp compute_power_budget(station_params, loss_budget) do
tx_power_dbm = station_params.tx_power_dbm
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi
# Typical receiver sensitivities by mode (dBm)
# CW ~-140, SSB ~-130, FM ~-120
rx_sensitivity_cw = -140.0
rx_sensitivity_ssb = -130.0
margin_cw = rx_power_dbm - rx_sensitivity_cw
margin_ssb = rx_power_dbm - rx_sensitivity_ssb
%{
tx_power_dbm: Float.round(tx_power_dbm, 1),
eirp_dbm: Float.round(eirp_dbm, 1),
rx_power_dbm: Float.round(rx_power_dbm, 1),
margin_cw: Float.round(margin_cw, 1),
margin_ssb: Float.round(margin_ssb, 1)
}
end
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
# ── Score tier helpers ── # ── Score tier helpers ──
defp tier_label(score) when score >= 80, do: "EXCELLENT" defp tier_label(score) when score >= 80, do: "EXCELLENT"

View file

@ -334,6 +334,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
type="text" type="text"
label="Callsign / grid / lat,lon" label="Callsign / grid / lat,lon"
placeholder="W5LUA or EM13qc or 32.91, -97.06" placeholder="W5LUA or EM13qc or 32.91, -97.06"
phx-debounce="600"
/> />
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="flex-1"> <div class="flex-1">

View file

@ -171,22 +171,20 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
<div :if={@path.result} class="space-y-4"> <div :if={@path.result} class="space-y-4">
<%!-- Elevation profile chart feet on Y, miles on X. <%!-- Elevation profile chart feet on Y, miles on X.
Same JS hook /path uses, fed from the cached point list. --%> Same JS hook /path uses (single data-profile attribute, not
multiple data-* the hook reads `el.dataset.profile`). --%>
<div :if={@profile_payload} class="card bg-base-100 border border-base-300 p-4"> <div :if={@profile_payload} class="card bg-base-100 border border-base-300 p-4">
<h3 class="font-semibold mb-2">Terrain profile</h3> <h3 class="font-semibold mb-2">Terrain profile</h3>
<div <div
id={"elevation-profile-#{@path.id}"} id={"elevation-profile-#{@path.id}"}
phx-hook="ElevationProfile" phx-hook="ElevationProfile"
phx-update="ignore" phx-update="ignore"
data-points={Jason.encode!(@profile_payload.points)} data-profile={Jason.encode!(@profile_payload)}
data-freq-mhz={@profile_payload.freq_mhz}
data-dist-km={@profile_payload.dist_km}
data-verdict={@profile_payload.verdict || "CLEAR"}
data-ducts="[]"
data-station1={@profile_payload.station1 || ""} data-station1={@profile_payload.station1 || ""}
data-station2={@profile_payload.station2 || ""} data-station2={@profile_payload.station2 || ""}
class="h-[300px]" class="w-full h-48 md:h-56 rounded-box overflow-hidden bg-base-200 p-2"
> >
<canvas></canvas>
</div> </div>
</div> </div>

View file

@ -322,12 +322,10 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp stationary_grid(_), do: nil defp stationary_grid(_), do: nil
# Endpoint string used for /path?destination=…. Prefers callsign, then # Endpoint string used for /path?destination=…. Prefers callsign, then
# Each row links to the CACHED stored-path detail (mission/paths/:id). # Row click goes straight to /path with the cached rover-path ID.
# That view renders straight from the worker-computed result map, so # PathLive detects the param, deserializes the worker-stored
# the user doesn't pay the full /path recompute cost. /path is still # PathCompute result, and renders without re-running the pipeline.
# one click away inside that view for the live HRRR recompute. defp path_url(_mission, %{id: path_id}) when not is_nil(path_id), do: "/path?rover_path_id=#{path_id}"
defp path_url(%Mission{id: mission_id}, %{id: path_id}) when not is_nil(path_id),
do: "/rover-planning/#{mission_id}/paths/#{path_id}"
defp path_url(_, _), do: nil defp path_url(_, _), do: nil

View file

@ -322,12 +322,11 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
[path | _] = Repo.all(Path) [path | _] = Repo.all(Path)
rendered = render(lv) rendered = render(lv)
# Each row navigates to the cached path-detail page so the user # Each row navigates to /path?rover_path_id=UUID — PathLive
# doesn't pay the live /path recompute cost. The live link is # detects the param, deserializes the worker-cached compute
# still available from inside that page. # output, and skips the live recompute entirely.
assert rendered =~ "phx-click" assert rendered =~ "phx-click"
assert rendered =~ "/rover-planning/#{mission.id}/paths/#{path.id}" assert rendered =~ "/path?rover_path_id=#{path.id}"
refute rendered =~ "/path?"
end end
test "groups paths by rover location", %{conn: conn} do test "groups paths by rover location", %{conn: conn} do