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
|
end
|
||||||
|
|
||||||
# When `input` looks like a callsign or grid, geocode it and populate
|
# When `input` looks like a callsign or grid, geocode it and populate
|
||||||
# the matching field. Skips resolution when the user already provided
|
# the matching field. If the user TYPED a new input (changeset has a
|
||||||
# explicit lat/lon — they may want a free-form coordinate that doesn't
|
# `:input` change), wipe any previously-resolved callsign / grid /
|
||||||
# round-trip through callsign lookup.
|
# lat / lon first — otherwise an early prefix that resolved
|
||||||
|
# successfully (e.g. "AA5" matched a callsign) would lock those
|
||||||
|
# fields and the user's later keystrokes would never re-resolve.
|
||||||
defp resolve_input(changeset) do
|
defp resolve_input(changeset) do
|
||||||
cond do
|
cond do
|
||||||
not changeset.valid? ->
|
not changeset.valid? ->
|
||||||
changeset
|
changeset
|
||||||
|
|
||||||
|
input_changed?(changeset) ->
|
||||||
|
changeset
|
||||||
|
|> clear_resolved()
|
||||||
|
|> resolve_from_input()
|
||||||
|
|
||||||
get_field(changeset, :lat) && get_field(changeset, :lon) ->
|
get_field(changeset, :lat) && get_field(changeset, :lon) ->
|
||||||
changeset
|
changeset
|
||||||
|
|
||||||
input = get_field(changeset, :input) ->
|
true ->
|
||||||
|
resolve_from_input(changeset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp input_changed?(changeset), do: not is_nil(get_change(changeset, :input))
|
||||||
|
|
||||||
|
defp clear_resolved(changeset) do
|
||||||
|
changeset
|
||||||
|
|> put_change(:callsign, nil)
|
||||||
|
|> put_change(:grid, nil)
|
||||||
|
|> put_change(:lat, nil)
|
||||||
|
|> put_change(:lon, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_from_input(changeset) do
|
||||||
|
case get_field(changeset, :input) do
|
||||||
|
input when is_binary(input) and input != "" ->
|
||||||
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
|
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
|
||||||
|
|
||||||
true ->
|
_ ->
|
||||||
changeset
|
changeset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Computes a terrain path profile for one rover-location → station
|
Pre-computes one rover-planning path so the live `/path` page can
|
||||||
pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous
|
render instantly when the operator clicks through. Calls the same
|
||||||
`MicrowavepropWeb.PathLive` compute pipeline (elevation profile +
|
`Microwaveprop.Propagation.PathCompute.compute/4` pipeline `/path`
|
||||||
ITU-R P.526 analysis at the mission's band + heights) and stores
|
uses (terrain analysis, 9 HRRR profile samples, sounding +
|
||||||
the result as JSON on the matching `RoverPlanning.Path`.
|
ionosphere readouts, scoring, link + power budgets, forecast) and
|
||||||
|
stores the full output on the matching `RoverPlanning.Path`.
|
||||||
|
|
||||||
The HRRR-along-path step from `/path` is intentionally skipped here:
|
The map is persisted in two parallel forms:
|
||||||
it depends on real-time profile grids that aren't worth pinning to a
|
* Flat string-keyed fields (`distance_km`, `verdict`,
|
||||||
stored mission result. The rendered show page can re-fetch live
|
`propagation_score`, `total_baseline_loss_db`, `path_points`,
|
||||||
weather on demand if needed.
|
…) — what the rover-planning show table reads at-a-glance.
|
||||||
|
* `term` — the entire atom-keyed compute output, encoded with
|
||||||
|
`:erlang.term_to_binary/1` + Base64. PathLive decodes this when
|
||||||
|
handed `?rover_path_id=UUID` and renders without recomputing.
|
||||||
"""
|
"""
|
||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :terrain,
|
queue: :terrain,
|
||||||
|
|
@ -22,22 +26,13 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Microwaveprop.Geo
|
alias Microwaveprop.Propagation.PathCompute
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
|
||||||
alias Microwaveprop.Propagation.ScoresFile
|
alias Microwaveprop.Propagation.ScoresFile
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.RoverPlanning.Path
|
alias Microwaveprop.RoverPlanning.Path
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
|
||||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
||||||
|
|
||||||
# Default surface absolute humidity (g/m³) — ~7.5 is the
|
|
||||||
# PathLive fallback when no live HRRR sample is available. Used to
|
|
||||||
# produce a "baseline" cached H₂O loss; the live `/path` page
|
|
||||||
# recomputes this from current weather.
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@default_abs_humidity_gm3 7.5
|
|
||||||
|
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def backoff(%Oban.Job{attempt: attempt}) do
|
def backoff(%Oban.Job{attempt: attempt}) do
|
||||||
min(60 * Integer.pow(2, attempt - 1), 3600)
|
min(60 * Integer.pow(2, attempt - 1), 3600)
|
||||||
|
|
@ -73,9 +68,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
# Legacy / malformed args (no band_mhz, or wrong types). Don't crash
|
|
||||||
# the queue — log enough context to debug and drop the job. New
|
|
||||||
# enqueues from `enqueue_paths_for/1` always include band_mhz.
|
|
||||||
defp load_path(args) do
|
defp load_path(args) do
|
||||||
Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}")
|
Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}")
|
||||||
|
|
||||||
|
|
@ -86,38 +78,33 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
mark_status(path, :computing)
|
mark_status(path, :computing)
|
||||||
|
|
||||||
band_mhz = path.band_mhz || mission.band_mhz
|
band_mhz = path.band_mhz || mission.band_mhz
|
||||||
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
|
src_height_ft = mission.rover_height_ft || 8.0
|
||||||
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
|
dst_height_ft = mission.station_height_ft || 30.0
|
||||||
freq_ghz = band_mhz / 1000
|
|
||||||
dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon)
|
|
||||||
bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon)
|
|
||||||
|
|
||||||
case ElevationClient.fetch_elevation_profile(
|
station_params = %{
|
||||||
rover.lat,
|
src_height_ft: src_height_ft,
|
||||||
rover.lon,
|
dst_height_ft: dst_height_ft,
|
||||||
station.lat,
|
src_height_m: src_height_ft * 0.3048,
|
||||||
station.lon,
|
dst_height_m: dst_height_ft * 0.3048,
|
||||||
64,
|
# Defaults match PathLive's form fallback (10 W TX, 30 dBi gains
|
||||||
download: true
|
# at each end). Operators can override on the live `/path` page;
|
||||||
) do
|
# the cached snapshot uses these so power-budget numbers are
|
||||||
{:ok, profile} ->
|
# representative for a typical microwave home station.
|
||||||
analysis =
|
tx_power_dbm: 20.0,
|
||||||
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
|
tx_power_mw: :math.pow(10, 20.0 / 10),
|
||||||
ant_ht_a: rover_height_m,
|
src_gain_dbi: 30.0,
|
||||||
ant_ht_b: station_height_m
|
dst_gain_dbi: 30.0
|
||||||
)
|
}
|
||||||
|
|
||||||
midlat = (rover.lat + station.lat) / 2
|
src = "#{rover.lat},#{rover.lon}"
|
||||||
midlon = (rover.lon + station.lon) / 2
|
dst = "#{station.lat},#{station.lon}"
|
||||||
propagation_score = lookup_propagation_score(band_mhz, midlat, midlon)
|
|
||||||
|
|
||||||
loss_budget =
|
case PathCompute.compute(src, dst, band_mhz, station_params) do
|
||||||
compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db)
|
{:ok, result} ->
|
||||||
|
store_complete(path, result, band_mhz, rover, station)
|
||||||
store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
store_failed(path, "elevation profile failed: #{inspect(reason)}")
|
store_failed(path, "compute failed: #{inspect(reason)}")
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
e ->
|
e ->
|
||||||
|
|
@ -126,47 +113,15 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
reraise e, __STACKTRACE__
|
reraise e, __STACKTRACE__
|
||||||
end
|
end
|
||||||
|
|
||||||
defp store_complete(path, _profile, analysis, dist_km, bearing, propagation_score, loss_budget) do
|
defp store_complete(path, result, band_mhz, rover, station) do
|
||||||
# Store the FULL per-point analysis (beam height, Fresnel-zone radius,
|
flat = flat_summary(result, band_mhz, rover, station)
|
||||||
# clearance) so the elevation-profile chart on the cached path-detail
|
|
||||||
# page can render the same way `/path` does — line-of-sight + Fresnel
|
|
||||||
# overlay — without recomputing TerrainAnalysis on click.
|
|
||||||
points =
|
|
||||||
Enum.map(analysis.points, fn p ->
|
|
||||||
%{
|
|
||||||
"lat" => p.lat,
|
|
||||||
"lon" => p.lon,
|
|
||||||
"d" => p.d,
|
|
||||||
"dist_km" => p.dist_km,
|
|
||||||
"elev" => p.elev,
|
|
||||||
"beam" => Map.get(p, :beam, 0.0),
|
|
||||||
"r1" => Map.get(p, :r1, 0.0),
|
|
||||||
"clearance" => Map.get(p, :clearance, 0.0)
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
result = %{
|
full_term = result |> :erlang.term_to_binary() |> Base.encode64()
|
||||||
"distance_km" => dist_km,
|
|
||||||
"bearing_deg" => bearing,
|
|
||||||
"max_elevation_m" => analysis.max_elevation_m,
|
|
||||||
"min_clearance_m" => analysis.min_clearance_m,
|
|
||||||
"diffraction_db" => analysis.diffraction_db,
|
|
||||||
"fresnel_hit_count" => analysis.fresnel_hit_count,
|
|
||||||
"obstructed_count" => analysis.obstructed_count,
|
|
||||||
"verdict" => to_string(analysis.verdict),
|
|
||||||
"sample_count" => length(points),
|
|
||||||
"propagation_score" => propagation_score,
|
|
||||||
"free_space_loss_db" => loss_budget.free_space_loss_db,
|
|
||||||
"oxygen_loss_db" => loss_budget.oxygen_loss_db,
|
|
||||||
"humidity_loss_db_baseline" => loss_budget.humidity_loss_db_baseline,
|
|
||||||
"total_baseline_loss_db" => loss_budget.total_baseline_loss_db,
|
|
||||||
"path_points" => points
|
|
||||||
}
|
|
||||||
|
|
||||||
path
|
path
|
||||||
|> Path.changeset(%{
|
|> Path.changeset(%{
|
||||||
status: :complete,
|
status: :complete,
|
||||||
result: result,
|
result: Map.put(flat, "term", full_term),
|
||||||
error: nil,
|
error: nil,
|
||||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
})
|
})
|
||||||
|
|
@ -181,6 +136,76 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The handful of fields the rover-planning show table renders inline
|
||||||
|
# (distance, verdict, propagation score, total baseline loss). Lifted
|
||||||
|
# out of the full compute output so the table can read them directly
|
||||||
|
# without round-tripping the term blob.
|
||||||
|
defp flat_summary(result, band_mhz, rover, station) do
|
||||||
|
analysis = result.terrain && result.terrain.analysis
|
||||||
|
midlat = (rover.lat + station.lat) / 2
|
||||||
|
midlon = (rover.lon + station.lon) / 2
|
||||||
|
|
||||||
|
result
|
||||||
|
|> geometry_summary(analysis)
|
||||||
|
|> Map.merge(loss_summary(result.loss_budget))
|
||||||
|
|> Map.put("propagation_score", lookup_propagation_score(band_mhz, midlat, midlon))
|
||||||
|
|> Map.put("path_points", path_points(analysis))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp geometry_summary(result, nil) do
|
||||||
|
%{
|
||||||
|
"distance_km" => result.dist_km,
|
||||||
|
"bearing_deg" => result.bearing,
|
||||||
|
"max_elevation_m" => nil,
|
||||||
|
"min_clearance_m" => nil,
|
||||||
|
"diffraction_db" => 0.0,
|
||||||
|
"fresnel_hit_count" => 0,
|
||||||
|
"obstructed_count" => 0,
|
||||||
|
"verdict" => nil,
|
||||||
|
"sample_count" => nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp geometry_summary(result, analysis) do
|
||||||
|
%{
|
||||||
|
"distance_km" => result.dist_km,
|
||||||
|
"bearing_deg" => result.bearing,
|
||||||
|
"max_elevation_m" => analysis.max_elevation_m,
|
||||||
|
"min_clearance_m" => analysis.min_clearance_m,
|
||||||
|
"diffraction_db" => analysis.diffraction_db,
|
||||||
|
"fresnel_hit_count" => analysis.fresnel_hit_count,
|
||||||
|
"obstructed_count" => analysis.obstructed_count,
|
||||||
|
"verdict" => to_string(analysis.verdict),
|
||||||
|
"sample_count" => length(analysis.points)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp loss_summary(loss) do
|
||||||
|
%{
|
||||||
|
"free_space_loss_db" => loss.fspl,
|
||||||
|
"oxygen_loss_db" => loss.o2,
|
||||||
|
"humidity_loss_db_baseline" => loss.h2o,
|
||||||
|
"total_baseline_loss_db" => loss.total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_points(nil), do: []
|
||||||
|
|
||||||
|
defp path_points(analysis) do
|
||||||
|
Enum.map(analysis.points, fn p ->
|
||||||
|
%{
|
||||||
|
"lat" => p.lat,
|
||||||
|
"lon" => p.lon,
|
||||||
|
"d" => p.d,
|
||||||
|
"dist_km" => p.dist_km,
|
||||||
|
"elev" => p.elev,
|
||||||
|
"beam" => Map.get(p, :beam, 0.0),
|
||||||
|
"r1" => Map.get(p, :r1, 0.0),
|
||||||
|
"clearance" => Map.get(p, :clearance, 0.0)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp store_failed(path, message) do
|
defp store_failed(path, message) do
|
||||||
path
|
path
|
||||||
|> Path.changeset(%{
|
|> Path.changeset(%{
|
||||||
|
|
@ -209,35 +234,6 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Cached baseline link-loss components for a path. Mirrors the
|
|
||||||
# `compute_loss_budget` math in PathLive but without live HRRR
|
|
||||||
# weather: humidity defaults to @default_abs_humidity_gm3 and rain is
|
|
||||||
# treated as zero. The `/path` page recomputes these against current
|
|
||||||
# conditions; this snapshot is what the rover-planning show page
|
|
||||||
# renders without needing HRRR access.
|
|
||||||
defp compute_loss_budget(dist_km, band_mhz, freq_ghz, diffraction_db) do
|
|
||||||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
|
||||||
freq_mhz = freq_ghz * 1000
|
|
||||||
|
|
||||||
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
|
|
||||||
o2_loss = (band_config.o2_db_km || 0.0) * dist_km
|
|
||||||
h2o_loss = (band_config.h2o_coeff || 0.0) * @default_abs_humidity_gm3 * dist_km
|
|
||||||
diffraction = diffraction_db * 1.0
|
|
||||||
total = fspl + o2_loss + h2o_loss + diffraction
|
|
||||||
|
|
||||||
%{
|
|
||||||
free_space_loss_db: Float.round(fspl, 1),
|
|
||||||
oxygen_loss_db: Float.round(o2_loss, 2),
|
|
||||||
humidity_loss_db_baseline: Float.round(h2o_loss, 2),
|
|
||||||
total_baseline_loss_db: Float.round(total, 1)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Look up the propagation grid score (0-100) for the midpoint of a
|
|
||||||
# path at the most recent forecast time available on disk. Returns
|
|
||||||
# nil when no scores file exists for this band yet — callers (the
|
|
||||||
# show page, primarily) render that as "—" without distinguishing
|
|
||||||
# "no data" from "score really is 0".
|
|
||||||
defp lookup_propagation_score(band_mhz, lat, lon) do
|
defp lookup_propagation_score(band_mhz, lat, lon) do
|
||||||
case ScoresFile.list_valid_times(band_mhz) do
|
case ScoresFile.list_valid_times(band_mhz) do
|
||||||
[] ->
|
[] ->
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,12 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
||||||
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
||||||
alias Microwaveprop.Canopy
|
alias Microwaveprop.Canopy
|
||||||
alias Microwaveprop.Ionosphere
|
|
||||||
alias Microwaveprop.Propagation
|
alias Microwaveprop.Propagation
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Propagation.ProfilesFile
|
alias Microwaveprop.Propagation.PathCompute
|
||||||
alias Microwaveprop.Propagation.Scorer
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Propagation.SporadicE
|
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.RoverPlanning.Path, as: RoverPath
|
||||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
||||||
alias Microwaveprop.Weather
|
|
||||||
alias Microwaveprop.Weather.Station
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -63,6 +58,47 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_params(%{"rover_path_id" => rover_path_id} = _params, _uri, socket)
|
||||||
|
when is_binary(rover_path_id) and rover_path_id != "" do
|
||||||
|
case load_cached_path(rover_path_id) do
|
||||||
|
{:ok, path, result} ->
|
||||||
|
params = params_from_cached(path, result)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
assign(socket,
|
||||||
|
source: params["source"],
|
||||||
|
destination: params["destination"],
|
||||||
|
band: params["band"],
|
||||||
|
src_height_ft: params["src_height_ft"],
|
||||||
|
dst_height_ft: params["dst_height_ft"],
|
||||||
|
tx_power_dbm: params["tx_power_dbm"],
|
||||||
|
src_gain_dbi: params["src_gain_dbi"],
|
||||||
|
dst_gain_dbi: params["dst_gain_dbi"],
|
||||||
|
source_is_gps: false,
|
||||||
|
result: result,
|
||||||
|
computing: false,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Cached path profile not found.")
|
||||||
|
|> push_navigate(to: ~p"/path")}
|
||||||
|
|
||||||
|
{:error, :stale_term} ->
|
||||||
|
# Cached blob can't be deserialized (e.g. old enrichment from
|
||||||
|
# before this code shipped). Fall through to live recompute
|
||||||
|
# using the path's stored source/dest/band.
|
||||||
|
case load_cached_path_for_redirect(rover_path_id) do
|
||||||
|
{:ok, params} -> {:noreply, push_patch(socket, to: ~p"/path?#{params}", replace: true)}
|
||||||
|
{:error, _} -> {:noreply, push_navigate(socket, to: ~p"/path")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
p = Map.merge(@defaults, Map.take(params, @url_params))
|
p = Map.merge(@defaults, Map.take(params, @url_params))
|
||||||
|
|
||||||
|
|
@ -94,6 +130,82 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Loads a cached rover-planning Path and rebuilds the @result map
|
||||||
|
# PathLive's render expects. The blob lives under `result["term"]`
|
||||||
|
# (Base64-encoded `:erlang.term_to_binary/1` of PathCompute's full
|
||||||
|
# output). `:safe` mode rejects unknown atoms — every atom we
|
||||||
|
# roundtrip is one this codebase already references at compile time,
|
||||||
|
# so it's known.
|
||||||
|
defp load_cached_path(rover_path_id) do
|
||||||
|
with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id),
|
||||||
|
%RoverPath{result: %{"term" => term}} <- Repo.get(RoverPath, uuid),
|
||||||
|
{:ok, decoded} <- decode_term(term) do
|
||||||
|
{:ok, nil, decoded}
|
||||||
|
else
|
||||||
|
%RoverPath{} -> {:error, :stale_term}
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
:error -> {:error, :not_found}
|
||||||
|
{:error, _} = err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fall-back: when the cached term is missing/stale, redirect to
|
||||||
|
# /path with the source/dest/band fields filled in from the Path's
|
||||||
|
# rover_location + station so the user gets a live recompute.
|
||||||
|
defp load_cached_path_for_redirect(rover_path_id) do
|
||||||
|
with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id),
|
||||||
|
%RoverPath{} = path <-
|
||||||
|
RoverPath |> Repo.get(uuid) |> Repo.preload([:rover_location, :station, :mission]) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
"source" => Maidenhead.from_latlon(path.rover_location.lat, path.rover_location.lon, 10),
|
||||||
|
"destination" =>
|
||||||
|
path.station.callsign || path.station.grid ||
|
||||||
|
"#{path.station.lat},#{path.station.lon}",
|
||||||
|
"band" => Integer.to_string(path.band_mhz || path.mission.band_mhz),
|
||||||
|
"src_height_ft" => Float.to_string((path.mission.rover_height_ft || 8.0) * 1.0),
|
||||||
|
"dst_height_ft" => Float.to_string((path.mission.station_height_ft || 30.0) * 1.0)
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_term(term) when is_binary(term) do
|
||||||
|
with {:ok, binary} <- Base.decode64(term),
|
||||||
|
decoded when is_map(decoded) <- safe_binary_to_term(binary) do
|
||||||
|
{:ok, decoded}
|
||||||
|
else
|
||||||
|
_ -> {:error, :stale_term}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_ -> {:error, :stale_term}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_binary_to_term(binary) do
|
||||||
|
:erlang.binary_to_term(binary, [:safe])
|
||||||
|
rescue
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp params_from_cached(_path, result) do
|
||||||
|
sp = result.station_params || %{}
|
||||||
|
|
||||||
|
%{
|
||||||
|
"source" => label_for(result.source),
|
||||||
|
"destination" => label_for(result.destination),
|
||||||
|
"band" => Integer.to_string(result.band_mhz),
|
||||||
|
"src_height_ft" => Float.to_string((Map.get(sp, :src_height_ft) || 30.0) * 1.0),
|
||||||
|
"dst_height_ft" => Float.to_string((Map.get(sp, :dst_height_ft) || 30.0) * 1.0),
|
||||||
|
"tx_power_dbm" => Float.to_string((Map.get(sp, :tx_power_dbm) || 20.0) * 1.0),
|
||||||
|
"src_gain_dbi" => Float.to_string((Map.get(sp, :src_gain_dbi) || 30.0) * 1.0),
|
||||||
|
"dst_gain_dbi" => Float.to_string((Map.get(sp, :dst_gain_dbi) || 30.0) * 1.0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp label_for(%{label: label}) when is_binary(label), do: label
|
||||||
|
defp label_for(_), do: ""
|
||||||
|
|
||||||
defp auto_calculate(socket, p) do
|
defp auto_calculate(socket, p) do
|
||||||
if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
|
if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
|
||||||
not socket.assigns.computing and is_nil(socket.assigns.result) do
|
not socket.assigns.computing and is_nil(socket.assigns.result) do
|
||||||
|
|
@ -263,420 +375,9 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_path(source, dest, band_mhz, station_params) do
|
defp compute_path(source, dest, band_mhz, station_params) do
|
||||||
with {:ok, src} <- resolve_location(source),
|
PathCompute.compute(source, dest, band_mhz, station_params)
|
||||||
{:ok, dst} <- resolve_location(dest) do
|
|
||||||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
|
||||||
freq_ghz = band_mhz / 1000
|
|
||||||
|
|
||||||
dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
|
|
||||||
bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)
|
|
||||||
|
|
||||||
# Terrain profile with antenna heights
|
|
||||||
terrain_result =
|
|
||||||
case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
|
|
||||||
{:ok, profile} ->
|
|
||||||
analysis =
|
|
||||||
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
|
|
||||||
ant_ht_a: station_params.src_height_m,
|
|
||||||
ant_ht_b: station_params.dst_height_m
|
|
||||||
)
|
|
||||||
|
|
||||||
%{profile: profile, analysis: analysis}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.warning(
|
|
||||||
"PathLive terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# HRRR profiles along path — 9 evenly-spaced samples so mid-path
|
|
||||||
# ducts that endpoint-only queries miss show up in the duct count.
|
|
||||||
# Source preference: the on-disk grid profile store (full-CONUS
|
|
||||||
# coverage from the hourly chain) is checked first; the per-QSO
|
|
||||||
# `hrrr_profiles` DB table is only the fallback for points the
|
|
||||||
# current chain hasn't published yet.
|
|
||||||
now = DateTime.utc_now()
|
|
||||||
midlat = (src.lat + dst.lat) / 2
|
|
||||||
midlon = (src.lon + dst.lon) / 2
|
|
||||||
|
|
||||||
profile_valid_time = latest_profile_valid_time(now)
|
|
||||||
# Load the full profile grid ONCE (single NFS read + gunzip + decode)
|
|
||||||
# instead of letting 9 async_stream workers each re-decode the same
|
|
||||||
# ~30 MB file. Each per-point lookup becomes a Map.get.
|
|
||||||
profile_grid = profile_grid_for(profile_valid_time)
|
|
||||||
|
|
||||||
sample_points = path_sample_points(src, dst, 9)
|
|
||||||
|
|
||||||
# Fast path: in-memory Map.get against the single decoded grid.
|
|
||||||
# Only points that miss the grid (e.g. just-rolled-over chain) take
|
|
||||||
# the slow DB-fallback path concurrently below.
|
|
||||||
{grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time)
|
|
||||||
|
|
||||||
fallback_hits =
|
|
||||||
misses
|
|
||||||
|> Enum.reverse()
|
|
||||||
|> Task.async_stream(&fallback_hrrr_point(&1, now), max_concurrency: 4, timeout: 5_000, on_timeout: :kill_task)
|
|
||||||
|> Enum.zip(Enum.reverse(misses))
|
|
||||||
|> Enum.flat_map(fn
|
|
||||||
{{:ok, nil}, _} ->
|
|
||||||
[]
|
|
||||||
|
|
||||||
{{:ok, point}, _} ->
|
|
||||||
[point]
|
|
||||||
|
|
||||||
{{:exit, reason}, {label, lat, lon}} ->
|
|
||||||
Logger.error(
|
|
||||||
"PathLive HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
[]
|
|
||||||
end)
|
|
||||||
|
|
||||||
hrrr_points = Enum.reverse(grid_hits, fallback_hits)
|
|
||||||
|
|
||||||
hrrr_profiles = Enum.map(hrrr_points, & &1.profile)
|
|
||||||
|
|
||||||
# Independent duct signal: the nearest RAOB within 300 km / ±3 h of
|
|
||||||
# the midpoint. HRRR pressure levels systematically under-read thin
|
|
||||||
# surface ducts that sounding data resolves.
|
|
||||||
sounding = build_sounding_readout(midlat, midlon, now)
|
|
||||||
|
|
||||||
# Native-resolution HRRR duct band + Bulk Richardson near the
|
|
||||||
# midpoint. Resolves thin trapping layers HRRR's 13 pressure levels
|
|
||||||
# miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on
|
|
||||||
# Richardson so turbulent duct readings don't inflate the score.
|
|
||||||
#
|
|
||||||
# `:not_found` is the documented fallback when no native sample is
|
|
||||||
# within ±0.07° / ±1 h of the midpoint — the native profile table
|
|
||||||
# is sparsely populated (only points the per-QSO worker has
|
|
||||||
# touched). The empty-map fallback is correct behavior, not a
|
|
||||||
# failure, so we don't log it.
|
|
||||||
native_duct =
|
|
||||||
case Weather.nearest_native_duct_info(midlat, midlon, now) do
|
|
||||||
{:ok, info} -> info
|
|
||||||
{:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Build conditions and score
|
|
||||||
{conditions, scoring} =
|
|
||||||
build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)
|
|
||||||
|
|
||||||
# Loss and power budgets
|
|
||||||
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
|
|
||||||
power_budget = compute_power_budget(station_params, loss_budget)
|
|
||||||
|
|
||||||
# 18-hour forecast from propagation grid (midpoint of path)
|
|
||||||
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
|
|
||||||
|
|
||||||
# Ionosphere readout at the midpoint (sporadic-E potential).
|
|
||||||
# Only relevant for VHF where Es propagation is physically possible.
|
|
||||||
ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km)
|
|
||||||
|
|
||||||
{:ok,
|
|
||||||
%{
|
|
||||||
source: src,
|
|
||||||
destination: dst,
|
|
||||||
station_params: station_params,
|
|
||||||
band_config: band_config,
|
|
||||||
band_mhz: band_mhz,
|
|
||||||
freq_ghz: freq_ghz,
|
|
||||||
dist_km: dist_km,
|
|
||||||
bearing: bearing,
|
|
||||||
terrain: terrain_result,
|
|
||||||
conditions: conditions,
|
|
||||||
scoring: scoring,
|
|
||||||
loss_budget: loss_budget,
|
|
||||||
power_budget: power_budget,
|
|
||||||
forecast: forecast,
|
|
||||||
hrrr_count: length(hrrr_profiles),
|
|
||||||
hrrr_points: hrrr_points,
|
|
||||||
ionosphere: ionosphere,
|
|
||||||
sounding: sounding
|
|
||||||
}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns nil when there's no usable readout, or a map describing the
|
|
||||||
# nearest ionosonde's current foEs + the computed Es score for
|
|
||||||
# (band, distance). Only VHF bands get the readout.
|
|
||||||
defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
|
|
||||||
case Ionosphere.nearest_foes(midlat, midlon) do
|
|
||||||
{:ok, obs} ->
|
|
||||||
es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)
|
|
||||||
muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km)
|
|
||||||
|
|
||||||
%{
|
|
||||||
station_code: obs.station_code,
|
|
||||||
valid_time: obs.valid_time,
|
|
||||||
fo_es_mhz: obs.fo_es_mhz,
|
|
||||||
fo_f2_mhz: obs.fo_f2_mhz,
|
|
||||||
mufd_mhz: obs.mufd_mhz,
|
|
||||||
es_score: es_score,
|
|
||||||
es_muf_mhz: muf,
|
|
||||||
es_in_range?: dist_km >= 500 and dist_km <= 2500
|
|
||||||
}
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil
|
|
||||||
|
|
||||||
defp resolve_location(input) do
|
|
||||||
case MicrowavepropWeb.LocationResolver.resolve(input) do
|
|
||||||
:empty -> {:error, "Location is required"}
|
|
||||||
other -> other
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# The grid is the single decoded output of ProfilesFile.read/1 — keys are
|
|
||||||
# `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2.
|
|
||||||
# We mirror the same snapping locally instead of round-tripping through
|
|
||||||
# ProfilesFile.read_point/3 (which would re-fetch the cached grid).
|
|
||||||
defp profile_grid_for(nil), do: nil
|
|
||||||
|
|
||||||
defp profile_grid_for(valid_time) do
|
|
||||||
case ProfilesFile.read(valid_time) do
|
|
||||||
{:ok, grid} ->
|
|
||||||
grid
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.warning("PathLive ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}")
|
|
||||||
|
|
||||||
nil
|
|
||||||
|
|
||||||
other ->
|
|
||||||
Logger.warning("PathLive ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}")
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp lookup_profile_grid(grid, lat, lon) do
|
|
||||||
{snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon)
|
|
||||||
Map.get(grid, {snapped_lat, snapped_lon})
|
|
||||||
end
|
|
||||||
|
|
||||||
defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do
|
|
||||||
Enum.reduce(sample_points, {[], []}, fn pt, acc ->
|
|
||||||
classify_grid_hit(pt, profile_grid, profile_valid_time, acc)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]}
|
|
||||||
|
|
||||||
defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do
|
|
||||||
case lookup_profile_grid(grid, lat, lon) do
|
|
||||||
nil -> {hits, [pt | mss]}
|
|
||||||
cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fallback_hrrr_point({label, lat, lon}, now) do
|
|
||||||
case Weather.find_nearest_hrrr(lat, lon, now) do
|
|
||||||
nil -> nil
|
|
||||||
profile -> %{label: label, profile: profile}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# The most recent on-disk profile file at or before `now`. Falls back
|
|
||||||
# to the earliest available file if everything cached is in the future
|
|
||||||
# (cold-start state during a missed chain). Returns `nil` when the
|
|
||||||
# store is empty — caller then drops to the DB-table fallback.
|
|
||||||
defp latest_profile_valid_time(now) do
|
|
||||||
case ProfilesFile.list_valid_times() do
|
|
||||||
[] -> nil
|
|
||||||
times -> pick_latest_at_or_before(times, now)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp pick_latest_at_or_before(times, now) do
|
|
||||||
case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do
|
|
||||||
[] -> List.first(times)
|
|
||||||
past -> Enum.max(past, DateTime)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ProfilesFile cells are already flat HrrrProfile-shaped maps — the
|
|
||||||
# `:profile` key holds the vertical pressure-level list, NOT a wrapper
|
|
||||||
# around the surface fields. Stamp `lat`/`lon` (from the sample point,
|
|
||||||
# since cells don't carry their own coordinates — they're the map key)
|
|
||||||
# and `valid_time` so downstream template + scoring code is shape-agnostic.
|
|
||||||
defp profile_from_cell(cell, lat, lon, valid_time) do
|
|
||||||
cell
|
|
||||||
|> Map.put_new(:lat, lat)
|
|
||||||
|> Map.put_new(:lon, lon)
|
|
||||||
|> Map.put_new(:valid_time, valid_time)
|
|
||||||
|> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient))
|
|
||||||
|> Map.put_new(:surface_refractivity, nil)
|
|
||||||
|> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0)
|
|
||||||
|> Map.put_new(:duct_characteristics, nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Linear interpolation along the great-circle-approximate path. Good
|
|
||||||
# enough at the path-calculator's typical range (<2000 km); for longer
|
|
||||||
# paths the path deviates from a rhumb line but we're picking
|
|
||||||
# HRRR grid cells, not doing precise bearing.
|
|
||||||
defp path_sample_points(src, dst, count) when count >= 2 do
|
|
||||||
Enum.map(1..count, fn i ->
|
|
||||||
t = (i - 1) / (count - 1)
|
|
||||||
lat = src.lat + (dst.lat - src.lat) * t
|
|
||||||
lon = src.lon + (dst.lon - src.lon) * t
|
|
||||||
|
|
||||||
label =
|
|
||||||
cond do
|
|
||||||
i == 1 -> "Source"
|
|
||||||
i == count -> "Destination"
|
|
||||||
i * 2 == count + 1 -> "Midpoint"
|
|
||||||
true -> "#{round(t * 100)}%"
|
|
||||||
end
|
|
||||||
|
|
||||||
{label, lat, lon}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_sounding_readout(midlat, midlon, now) do
|
|
||||||
case Weather.nearest_sounding_to(midlat, midlon, now) do
|
|
||||||
{:ok, sounding} ->
|
|
||||||
station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station
|
|
||||||
station = station || Repo.get(Station, sounding.station_id)
|
|
||||||
|
|
||||||
distance_km =
|
|
||||||
if station, do: haversine_km(midlat, midlon, station.lat, station.lon)
|
|
||||||
|
|
||||||
%{
|
|
||||||
station_code: station && station.station_code,
|
|
||||||
station_name: station && station.name,
|
|
||||||
observed_at: sounding.observed_at,
|
|
||||||
ducting_detected: sounding.ducting_detected,
|
|
||||||
min_refractivity_gradient: sounding.min_refractivity_gradient,
|
|
||||||
boundary_layer_depth_m: sounding.boundary_layer_depth_m,
|
|
||||||
precipitable_water_mm: sounding.precipitable_water_mm,
|
|
||||||
distance_km: distance_km
|
|
||||||
}
|
|
||||||
|
|
||||||
{:error, :not_found} ->
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}
|
|
||||||
|
|
||||||
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
|
|
||||||
temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1)
|
|
||||||
dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1)
|
|
||||||
|
|
||||||
if temps == [] or dewpoints == [] do
|
|
||||||
{nil, nil}
|
|
||||||
else
|
|
||||||
avg_temp_c = Enum.sum(temps) / length(temps)
|
|
||||||
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
|
|
||||||
|
|
||||||
pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
|
|
||||||
gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
|
|
||||||
bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
|
|
||||||
pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)
|
|
||||||
|
|
||||||
conditions = %{
|
|
||||||
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
|
|
||||||
temp_f: Scorer.c_to_f(avg_temp_c),
|
|
||||||
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
|
|
||||||
temp_c: avg_temp_c,
|
|
||||||
dewpoint_c: avg_dewpoint_c,
|
|
||||||
wind_speed_kts: nil,
|
|
||||||
sky_cover_pct: nil,
|
|
||||||
utc_hour: now.hour,
|
|
||||||
utc_minute: now.minute,
|
|
||||||
month: now.month,
|
|
||||||
latitude: (src.lat + dst.lat) / 2,
|
|
||||||
longitude: src.lon,
|
|
||||||
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
|
|
||||||
prev_pressure_mb: nil,
|
|
||||||
rain_rate_mmhr: 0.0,
|
|
||||||
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
|
|
||||||
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
|
|
||||||
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
|
|
||||||
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
|
|
||||||
bulk_richardson: native_duct[:bulk_richardson]
|
|
||||||
}
|
|
||||||
|
|
||||||
scoring = Scorer.composite_score(conditions, band_config)
|
|
||||||
{conditions, scoring}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
|
|
||||||
freq_mhz = freq_ghz * 1000
|
|
||||||
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
|
|
||||||
|
|
||||||
o2_loss = band_config.o2_db_km * dist_km
|
|
||||||
h2o_coeff = band_config.h2o_coeff
|
|
||||||
|
|
||||||
abs_humidity =
|
|
||||||
if conditions do
|
|
||||||
conditions.abs_humidity
|
|
||||||
else
|
|
||||||
7.5
|
|
||||||
end
|
|
||||||
|
|
||||||
h2o_loss = h2o_coeff * abs_humidity * dist_km
|
|
||||||
|
|
||||||
rain_loss =
|
|
||||||
if conditions && conditions.rain_rate_mmhr > 0 do
|
|
||||||
gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
|
|
||||||
gamma * dist_km
|
|
||||||
else
|
|
||||||
0.0
|
|
||||||
end
|
|
||||||
|
|
||||||
diffraction_loss =
|
|
||||||
if terrain_result do
|
|
||||||
# TerrainAnalysis.deygout_diffraction returns integer 0 for clear
|
|
||||||
# paths; coerce to float so Float.round/2 below is happy.
|
|
||||||
terrain_result.analysis.diffraction_db * 1.0
|
|
||||||
else
|
|
||||||
0.0
|
|
||||||
end
|
|
||||||
|
|
||||||
total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss
|
|
||||||
|
|
||||||
%{
|
|
||||||
fspl: Float.round(fspl, 1),
|
|
||||||
o2: Float.round(o2_loss, 2),
|
|
||||||
h2o: Float.round(h2o_loss, 2),
|
|
||||||
rain: Float.round(rain_loss, 2),
|
|
||||||
diffraction: Float.round(diffraction_loss, 1),
|
|
||||||
total: Float.round(total, 1)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp compute_power_budget(station_params, loss_budget) do
|
|
||||||
tx_power_dbm = station_params.tx_power_dbm
|
|
||||||
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
|
|
||||||
rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi
|
|
||||||
|
|
||||||
# Typical receiver sensitivities by mode (dBm)
|
|
||||||
# CW ~-140, SSB ~-130, FM ~-120
|
|
||||||
rx_sensitivity_cw = -140.0
|
|
||||||
rx_sensitivity_ssb = -130.0
|
|
||||||
margin_cw = rx_power_dbm - rx_sensitivity_cw
|
|
||||||
margin_ssb = rx_power_dbm - rx_sensitivity_ssb
|
|
||||||
|
|
||||||
%{
|
|
||||||
tx_power_dbm: Float.round(tx_power_dbm, 1),
|
|
||||||
eirp_dbm: Float.round(eirp_dbm, 1),
|
|
||||||
rx_power_dbm: Float.round(rx_power_dbm, 1),
|
|
||||||
margin_cw: Float.round(margin_cw, 1),
|
|
||||||
margin_ssb: Float.round(margin_ssb, 1)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
|
|
||||||
defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
|
|
||||||
|
|
||||||
# ── Score tier helpers ──
|
# ── Score tier helpers ──
|
||||||
|
|
||||||
defp tier_label(score) when score >= 80, do: "EXCELLENT"
|
defp tier_label(score) when score >= 80, do: "EXCELLENT"
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||||
type="text"
|
type="text"
|
||||||
label="Callsign / grid / lat,lon"
|
label="Callsign / grid / lat,lon"
|
||||||
placeholder="W5LUA or EM13qc or 32.91, -97.06"
|
placeholder="W5LUA or EM13qc or 32.91, -97.06"
|
||||||
|
phx-debounce="600"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|
|
||||||
|
|
@ -171,22 +171,20 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|
||||||
|
|
||||||
<div :if={@path.result} class="space-y-4">
|
<div :if={@path.result} class="space-y-4">
|
||||||
<%!-- Elevation profile chart — feet on Y, miles on X.
|
<%!-- Elevation profile chart — feet on Y, miles on X.
|
||||||
Same JS hook /path uses, fed from the cached point list. --%>
|
Same JS hook /path uses (single data-profile attribute, not
|
||||||
|
multiple data-* — the hook reads `el.dataset.profile`). --%>
|
||||||
<div :if={@profile_payload} class="card bg-base-100 border border-base-300 p-4">
|
<div :if={@profile_payload} class="card bg-base-100 border border-base-300 p-4">
|
||||||
<h3 class="font-semibold mb-2">Terrain profile</h3>
|
<h3 class="font-semibold mb-2">Terrain profile</h3>
|
||||||
<div
|
<div
|
||||||
id={"elevation-profile-#{@path.id}"}
|
id={"elevation-profile-#{@path.id}"}
|
||||||
phx-hook="ElevationProfile"
|
phx-hook="ElevationProfile"
|
||||||
phx-update="ignore"
|
phx-update="ignore"
|
||||||
data-points={Jason.encode!(@profile_payload.points)}
|
data-profile={Jason.encode!(@profile_payload)}
|
||||||
data-freq-mhz={@profile_payload.freq_mhz}
|
|
||||||
data-dist-km={@profile_payload.dist_km}
|
|
||||||
data-verdict={@profile_payload.verdict || "CLEAR"}
|
|
||||||
data-ducts="[]"
|
|
||||||
data-station1={@profile_payload.station1 || ""}
|
data-station1={@profile_payload.station1 || ""}
|
||||||
data-station2={@profile_payload.station2 || ""}
|
data-station2={@profile_payload.station2 || ""}
|
||||||
class="h-[300px]"
|
class="w-full h-48 md:h-56 rounded-box overflow-hidden bg-base-200 p-2"
|
||||||
>
|
>
|
||||||
|
<canvas></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -322,12 +322,10 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
defp stationary_grid(_), do: nil
|
defp stationary_grid(_), do: nil
|
||||||
|
|
||||||
# Endpoint string used for /path?destination=…. Prefers callsign, then
|
# Endpoint string used for /path?destination=…. Prefers callsign, then
|
||||||
# Each row links to the CACHED stored-path detail (mission/paths/:id).
|
# Row click goes straight to /path with the cached rover-path ID.
|
||||||
# That view renders straight from the worker-computed result map, so
|
# PathLive detects the param, deserializes the worker-stored
|
||||||
# the user doesn't pay the full /path recompute cost. /path is still
|
# PathCompute result, and renders without re-running the pipeline.
|
||||||
# one click away inside that view for the live HRRR recompute.
|
defp path_url(_mission, %{id: path_id}) when not is_nil(path_id), do: "/path?rover_path_id=#{path_id}"
|
||||||
defp path_url(%Mission{id: mission_id}, %{id: path_id}) when not is_nil(path_id),
|
|
||||||
do: "/rover-planning/#{mission_id}/paths/#{path_id}"
|
|
||||||
|
|
||||||
defp path_url(_, _), do: nil
|
defp path_url(_, _), do: nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -322,12 +322,11 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
[path | _] = Repo.all(Path)
|
[path | _] = Repo.all(Path)
|
||||||
rendered = render(lv)
|
rendered = render(lv)
|
||||||
|
|
||||||
# Each row navigates to the cached path-detail page so the user
|
# Each row navigates to /path?rover_path_id=UUID — PathLive
|
||||||
# doesn't pay the live /path recompute cost. The live link is
|
# detects the param, deserializes the worker-cached compute
|
||||||
# still available from inside that page.
|
# output, and skips the live recompute entirely.
|
||||||
assert rendered =~ "phx-click"
|
assert rendered =~ "phx-click"
|
||||||
assert rendered =~ "/rover-planning/#{mission.id}/paths/#{path.id}"
|
assert rendered =~ "/path?rover_path_id=#{path.id}"
|
||||||
refute rendered =~ "/path?"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "groups paths by rover location", %{conn: conn} do
|
test "groups paths by rover location", %{conn: conn} do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue