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:
parent
2327fabe29
commit
a4f0e171e8
8 changed files with 721 additions and 557 deletions
447
lib/microwaveprop/propagation/path_compute.ex
Normal file
447
lib/microwaveprop/propagation/path_compute.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
[] ->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue