Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 39s
1. RoverPathProfileWorker sandbox ownership: fallback_hits/2 used Task.async_stream spawning separate DB-querying processes that lacked Ecto sandbox ownership in test mode. Replaced with sequential Enum.map since miss list is ≤9 points — no meaningful perf impact and eliminates the sandbox race entirely. 2. PSKR client test: asserted '6m' band in defaults, but the actual microwave band name is '6cm'. Fixed assertion.
485 lines
16 KiB
Elixir
485 lines
16 KiB
Elixir
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 48-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.Ionosphere
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Propagation.ProfilesFile
|
|
alias Microwaveprop.Propagation.Scorer
|
|
alias Microwaveprop.Propagation.SporadicE
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
alias Microwaveprop.Weather
|
|
|
|
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
|
|
}
|
|
|
|
@type progress_fn :: (pos_integer(), pos_integer(), String.t() -> any())
|
|
|
|
@stages [
|
|
"Resolving locations",
|
|
"Analyzing terrain",
|
|
"Loading HRRR profile grid",
|
|
"Fetching atmospheric data along path",
|
|
"Loading sounding & duct data",
|
|
"Scoring propagation conditions",
|
|
"Computing link & power budget",
|
|
"Loading 48-hour forecast",
|
|
"Loading ionosphere data"
|
|
]
|
|
@total_stages length(@stages)
|
|
|
|
@doc "Total number of progress stages emitted by `compute/5`."
|
|
@spec total_stages() :: integer()
|
|
def total_stages, do: @total_stages
|
|
|
|
@doc "Ordered stage labels emitted by `compute/5` (1-indexed)."
|
|
@spec stage_labels() :: list(String.t())
|
|
def stage_labels, do: @stages
|
|
|
|
@doc """
|
|
Run the full path-calculator pipeline. Returns `{:ok, result}` on
|
|
success, `{:error, reason}` when either endpoint fails to resolve
|
|
(callsign / grid / coords).
|
|
|
|
Options:
|
|
* `:on_progress` — `(step, total, label)` callback invoked at the
|
|
start of each stage. Defaults to a no-op. Use it to push live
|
|
progress updates to a `Phoenix.LiveView`.
|
|
"""
|
|
@spec compute(String.t(), String.t(), integer(), station_params(), keyword()) ::
|
|
{:ok, result()} | {:error, term()}
|
|
def compute(source, dest, band_mhz, station_params, opts \\ []) do
|
|
on_progress = Keyword.get(opts, :on_progress, fn _step, _total, _label -> :ok end)
|
|
report = fn step -> on_progress.(step, @total_stages, Enum.at(@stages, step - 1)) end
|
|
|
|
report.(1)
|
|
|
|
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)
|
|
|
|
report.(2)
|
|
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
|
|
|
|
report.(3)
|
|
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)
|
|
|
|
report.(4)
|
|
fallback_hits = fallback_hits(misses, now)
|
|
|
|
hrrr_points = Enum.reverse(grid_hits, fallback_hits)
|
|
hrrr_profiles = Enum.map(hrrr_points, & &1.profile)
|
|
|
|
report.(5)
|
|
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
|
|
|
|
report.(6)
|
|
|
|
{conditions, scoring} =
|
|
build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)
|
|
|
|
report.(7)
|
|
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
|
|
power_budget = compute_power_budget(station_params, loss_budget)
|
|
|
|
report.(8)
|
|
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
|
|
|
|
report.(9)
|
|
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()
|
|
|> Enum.map(&fallback_hrrr_point(&1, now))
|
|
|> Enum.filter(& &1)
|
|
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
|
|
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 = sounding.station
|
|
|
|
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, dewpoints, pressures, gradients, bl_depths, pwats} = collect_profile_fields(profiles)
|
|
|
|
if temps == [] or dewpoints == [] do
|
|
{nil, nil}
|
|
else
|
|
avg_temp_c = Enum.sum(temps) / length(temps)
|
|
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
|
|
|
|
conditions =
|
|
build_conditions(
|
|
avg_temp_c,
|
|
avg_dewpoint_c,
|
|
src,
|
|
dst,
|
|
now,
|
|
{pressures, gradients, bl_depths, pwats},
|
|
native_duct
|
|
)
|
|
|
|
scoring = Scorer.composite_score(conditions, band_config)
|
|
{conditions, scoring}
|
|
end
|
|
end
|
|
|
|
defp collect_profile_fields(profiles) do
|
|
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {ts, ds, ps, gs, bs, ws} ->
|
|
{
|
|
if(p.surface_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
|
|
if(p.surface_dewpoint_c == nil, do: ds, else: [p.surface_dewpoint_c | ds]),
|
|
if(p.surface_pressure_mb == nil, do: ps, else: [p.surface_pressure_mb | ps]),
|
|
if(p.min_refractivity_gradient == nil,
|
|
do: gs,
|
|
else: [p.min_refractivity_gradient | gs]
|
|
),
|
|
if(p.hpbl_m == nil, do: bs, else: [p.hpbl_m | bs]),
|
|
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp build_conditions(avg_temp_c, avg_dewpoint_c, src, dst, now, {pressures, gradients, bl_depths, pwats}, native_duct) do
|
|
%{
|
|
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 + dst.lon) / 2,
|
|
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]
|
|
}
|
|
end
|
|
|
|
@doc false
|
|
@spec compute_loss_budget(float(), float(), map(), map() | nil, map() | nil) :: map()
|
|
def 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
|
|
Map.get(conditions, :abs_humidity) || 7.5
|
|
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
|
|
|
|
@doc false
|
|
@spec compute_power_budget(station_params(), map()) :: map()
|
|
def 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
|
|
end
|