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
# When `input` looks like a callsign or grid, geocode it and populate
# the matching field. Skips resolution when the user already provided
# explicit lat/lon — they may want a free-form coordinate that doesn't
# round-trip through callsign lookup.
# the matching field. If the user TYPED a new input (changeset has a
# `:input` change), wipe any previously-resolved callsign / grid /
# 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
cond do
not changeset.valid? ->
changeset
input_changed?(changeset) ->
changeset
|> clear_resolved()
|> resolve_from_input()
get_field(changeset, :lat) && get_field(changeset, :lon) ->
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)))
true ->
_ ->
changeset
end
end

View file

@ -1,15 +1,19 @@
defmodule Microwaveprop.Workers.RoverPathProfileWorker do
@moduledoc """
Computes a terrain path profile for one rover-location station
pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous
`MicrowavepropWeb.PathLive` compute pipeline (elevation profile +
ITU-R P.526 analysis at the mission's band + heights) and stores
the result as JSON on the matching `RoverPlanning.Path`.
Pre-computes one rover-planning path so the live `/path` page can
render instantly when the operator clicks through. Calls the same
`Microwaveprop.Propagation.PathCompute.compute/4` pipeline `/path`
uses (terrain analysis, 9 HRRR profile samples, sounding +
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:
it depends on real-time profile grids that aren't worth pinning to a
stored mission result. The rendered show page can re-fetch live
weather on demand if needed.
The map is persisted in two parallel forms:
* Flat string-keyed fields (`distance_km`, `verdict`,
`propagation_score`, `total_baseline_loss_db`, `path_points`,
) 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,
queue: :terrain,
@ -22,22 +26,13 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
import Ecto.Query
alias Microwaveprop.Geo
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.PathCompute
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo
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
@default_abs_humidity_gm3 7.5
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
min(60 * Integer.pow(2, attempt - 1), 3600)
@ -73,9 +68,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|> Repo.one()
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
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)
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 = 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)
src_height_ft = mission.rover_height_ft || 8.0
dst_height_ft = mission.station_height_ft || 30.0
case ElevationClient.fetch_elevation_profile(
rover.lat,
rover.lon,
station.lat,
station.lon,
64,
download: true
) do
{:ok, profile} ->
analysis =
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
ant_ht_a: rover_height_m,
ant_ht_b: station_height_m
)
station_params = %{
src_height_ft: src_height_ft,
dst_height_ft: dst_height_ft,
src_height_m: src_height_ft * 0.3048,
dst_height_m: dst_height_ft * 0.3048,
# Defaults match PathLive's form fallback (10 W TX, 30 dBi gains
# at each end). Operators can override on the live `/path` page;
# the cached snapshot uses these so power-budget numbers are
# representative for a typical microwave home station.
tx_power_dbm: 20.0,
tx_power_mw: :math.pow(10, 20.0 / 10),
src_gain_dbi: 30.0,
dst_gain_dbi: 30.0
}
midlat = (rover.lat + station.lat) / 2
midlon = (rover.lon + station.lon) / 2
propagation_score = lookup_propagation_score(band_mhz, midlat, midlon)
src = "#{rover.lat},#{rover.lon}"
dst = "#{station.lat},#{station.lon}"
loss_budget =
compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db)
store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget)
case PathCompute.compute(src, dst, band_mhz, station_params) do
{:ok, result} ->
store_complete(path, result, band_mhz, rover, station)
{:error, reason} ->
store_failed(path, "elevation profile failed: #{inspect(reason)}")
store_failed(path, "compute failed: #{inspect(reason)}")
end
rescue
e ->
@ -126,47 +113,15 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
reraise e, __STACKTRACE__
end
defp store_complete(path, _profile, analysis, dist_km, bearing, propagation_score, loss_budget) do
# Store the FULL per-point analysis (beam height, Fresnel-zone radius,
# 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)
defp store_complete(path, result, band_mhz, rover, station) do
flat = flat_summary(result, band_mhz, rover, station)
result = %{
"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
}
full_term = result |> :erlang.term_to_binary() |> Base.encode64()
path
|> Path.changeset(%{
status: :complete,
result: result,
result: Map.put(flat, "term", full_term),
error: nil,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
@ -181,6 +136,76 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
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
path
|> Path.changeset(%{
@ -209,35 +234,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
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
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.Loader, as: BuildingsLoader
alias Microwaveprop.Canopy
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Propagation.PathCompute
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Station
alias Microwaveprop.RoverPlanning.Path, as: RoverPath
require Logger
@ -63,6 +58,47 @@ defmodule MicrowavepropWeb.PathLive do
end
@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
p = Map.merge(@defaults, Map.take(params, @url_params))
@ -94,6 +130,82 @@ defmodule MicrowavepropWeb.PathLive do
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
if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
not socket.assigns.computing and is_nil(socket.assigns.result) do
@ -263,420 +375,9 @@ defmodule MicrowavepropWeb.PathLive do
end
defp compute_path(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 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
PathCompute.compute(source, dest, band_mhz, station_params)
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 ──
defp tier_label(score) when score >= 80, do: "EXCELLENT"

View file

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

View file

@ -171,22 +171,20 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
<div :if={@path.result} class="space-y-4">
<%!-- 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">
<h3 class="font-semibold mb-2">Terrain profile</h3>
<div
id={"elevation-profile-#{@path.id}"}
phx-hook="ElevationProfile"
phx-update="ignore"
data-points={Jason.encode!(@profile_payload.points)}
data-freq-mhz={@profile_payload.freq_mhz}
data-dist-km={@profile_payload.dist_km}
data-verdict={@profile_payload.verdict || "CLEAR"}
data-ducts="[]"
data-profile={Jason.encode!(@profile_payload)}
data-station1={@profile_payload.station1 || ""}
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>

View file

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

View file

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