HEEx attribute values aren't run through Elixir's string escape machinery, so "Feed Loss (\u00d72)" was showing up verbatim in the Power Budget card instead of collapsing to the multiplication sign. Swap the escape for the literal U+00D7 character.
1004 lines
34 KiB
Elixir
1004 lines
34 KiB
Elixir
defmodule MicrowavepropWeb.PathLive do
|
||
@moduledoc false
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Propagation.Scorer
|
||
alias Microwaveprop.Radio.CallsignClient
|
||
alias Microwaveprop.Radio.Maidenhead
|
||
alias Microwaveprop.Terrain.ElevationClient
|
||
alias Microwaveprop.Terrain.TerrainAnalysis
|
||
alias Microwaveprop.Weather
|
||
|
||
@band_options BandConfig.band_options()
|
||
|
||
@url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi)
|
||
@defaults %{
|
||
"source" => "",
|
||
"destination" => "",
|
||
"band" => "10000",
|
||
"src_height_ft" => "30",
|
||
"dst_height_ft" => "30",
|
||
"tx_power_dbm" => "20",
|
||
"src_gain_dbi" => "30",
|
||
"dst_gain_dbi" => "30"
|
||
}
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Path Calculator",
|
||
band_options: @band_options,
|
||
result: nil,
|
||
error: nil,
|
||
computing: false,
|
||
source: "",
|
||
destination: "",
|
||
band: "10000",
|
||
src_height_ft: "30",
|
||
dst_height_ft: "30",
|
||
tx_power_dbm: "20",
|
||
src_gain_dbi: "30",
|
||
dst_gain_dbi: "30",
|
||
source_is_gps: false
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _uri, socket) do
|
||
p = Map.merge(@defaults, Map.take(params, @url_params))
|
||
|
||
is_gps = p["source"] == "gps"
|
||
|
||
socket =
|
||
assign(socket,
|
||
source: if(is_gps, do: socket.assigns.source, else: p["source"]),
|
||
destination: p["destination"],
|
||
band: p["band"],
|
||
src_height_ft: p["src_height_ft"],
|
||
dst_height_ft: p["dst_height_ft"],
|
||
tx_power_dbm: p["tx_power_dbm"],
|
||
src_gain_dbi: p["src_gain_dbi"],
|
||
dst_gain_dbi: p["dst_gain_dbi"],
|
||
source_is_gps: is_gps
|
||
)
|
||
|
||
# If source=gps and we already have coords from a previous fix, just recalculate.
|
||
# Only request GPS from the device on the initial page load (no coords yet).
|
||
if p["source"] == "gps" and connected?(socket) do
|
||
if socket.assigns.source != "" and socket.assigns.source != nil do
|
||
auto_calculate(socket, Map.put(p, "source", socket.assigns.source))
|
||
else
|
||
{:noreply, push_event(socket, "request_gps", %{})}
|
||
end
|
||
else
|
||
auto_calculate(socket, p)
|
||
end
|
||
end
|
||
|
||
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
|
||
src_ht = parse_float(p["src_height_ft"], 30.0)
|
||
dst_ht = parse_float(p["dst_height_ft"], 30.0)
|
||
tx_dbm = parse_float(p["tx_power_dbm"], 20.0)
|
||
|
||
station_params = %{
|
||
src_height_ft: src_ht,
|
||
dst_height_ft: dst_ht,
|
||
src_height_m: src_ht * 0.3048,
|
||
dst_height_m: dst_ht * 0.3048,
|
||
tx_power_dbm: tx_dbm,
|
||
tx_power_mw: :math.pow(10, tx_dbm / 10),
|
||
src_gain_dbi: parse_float(p["src_gain_dbi"], 30.0),
|
||
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
|
||
}
|
||
|
||
send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params})
|
||
{:noreply, assign(socket, computing: true)}
|
||
else
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("update_form", params, socket) do
|
||
{:noreply,
|
||
assign(socket,
|
||
source: params["source"] || "",
|
||
destination: params["destination"] || "",
|
||
band: params["band"] || "10000",
|
||
src_height_ft: params["src_height_ft"] || "30",
|
||
dst_height_ft: params["dst_height_ft"] || "30",
|
||
tx_power_dbm: params["tx_power_dbm"] || "20",
|
||
src_gain_dbi: params["src_gain_dbi"] || "30",
|
||
dst_gain_dbi: params["dst_gain_dbi"] || "30"
|
||
)}
|
||
end
|
||
|
||
def handle_event("calculate", params, socket) do
|
||
src_height_ft = parse_float(params["src_height_ft"], 30.0)
|
||
dst_height_ft = parse_float(params["dst_height_ft"], 30.0)
|
||
|
||
tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0)
|
||
tx_power_mw = :math.pow(10, tx_power_dbm / 10)
|
||
|
||
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,
|
||
tx_power_dbm: tx_power_dbm,
|
||
tx_power_mw: tx_power_mw,
|
||
src_gain_dbi: parse_float(params["src_gain_dbi"], 30.0),
|
||
dst_gain_dbi: parse_float(params["dst_gain_dbi"], 30.0)
|
||
}
|
||
|
||
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"],
|
||
error: nil,
|
||
computing: true,
|
||
result: nil
|
||
)
|
||
|
||
send(
|
||
self(),
|
||
{:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params}
|
||
)
|
||
|
||
url_params =
|
||
params
|
||
|> Map.take(@url_params)
|
||
|> then(fn p -> if socket.assigns.source_is_gps, do: Map.put(p, "source", "gps"), else: p end)
|
||
|> Enum.reject(fn {_k, v} -> v == "" end)
|
||
|> Map.new()
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
|
||
end
|
||
|
||
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
|
||
coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"
|
||
|
||
# Show coords in the input but keep source=gps in the URL
|
||
socket = assign(socket, source: coords, source_is_gps: true, result: nil)
|
||
|
||
url_params =
|
||
%{
|
||
"source" => "gps",
|
||
"destination" => socket.assigns.destination,
|
||
"band" => socket.assigns.band,
|
||
"src_height_ft" => socket.assigns.src_height_ft,
|
||
"dst_height_ft" => socket.assigns.dst_height_ft,
|
||
"tx_power_dbm" => socket.assigns.tx_power_dbm,
|
||
"src_gain_dbi" => socket.assigns.src_gain_dbi,
|
||
"dst_gain_dbi" => socket.assigns.dst_gain_dbi
|
||
}
|
||
|> Enum.reject(fn {_k, v} -> v == "" end)
|
||
|> Map.new()
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:compute_path, source, dest, band_mhz, station_params}, socket) do
|
||
case compute_path(source, dest, band_mhz, station_params) do
|
||
{:ok, result} ->
|
||
{:noreply, assign(socket, result: result, computing: false)}
|
||
|
||
{:error, reason} ->
|
||
{:noreply, assign(socket, error: reason, computing: false)}
|
||
end
|
||
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, _} ->
|
||
nil
|
||
end
|
||
|
||
# HRRR profiles along path
|
||
now = DateTime.utc_now()
|
||
midlat = (src.lat + dst.lat) / 2
|
||
midlon = (src.lon + dst.lon) / 2
|
||
|
||
hrrr_profiles =
|
||
[{src.lat, src.lon}, {midlat, midlon}, {dst.lat, dst.lon}]
|
||
|> Enum.map(fn {lat, lon} -> Weather.find_nearest_hrrr(lat, lon, now) end)
|
||
|> Enum.reject(&is_nil/1)
|
||
|
||
# Build conditions and score
|
||
{conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config)
|
||
|
||
# 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)
|
||
|
||
{: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)
|
||
}}
|
||
end
|
||
end
|
||
|
||
defp resolve_location(input) do
|
||
input = String.trim(input)
|
||
|
||
cond do
|
||
input == "" ->
|
||
{:error, "Location is required"}
|
||
|
||
coordinate_pair?(input) ->
|
||
[lat_s, lon_s] = String.split(input, ",", parts: 2)
|
||
{lat, _} = Float.parse(String.trim(lat_s))
|
||
{lon, _} = Float.parse(String.trim(lon_s))
|
||
{:ok, %{lat: lat, lon: lon, label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}", type: :coordinates}}
|
||
|
||
maidenhead?(input) ->
|
||
case Maidenhead.to_latlon(input) do
|
||
{:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), type: :grid}}
|
||
:error -> {:error, "Invalid grid square: #{input}"}
|
||
end
|
||
|
||
true ->
|
||
case CallsignClient.locate(input) do
|
||
{:ok, info} ->
|
||
{:ok,
|
||
%{lat: info.lat, lon: info.lon, label: String.upcase(info.callsign), grid: info.gridsquare, type: :callsign}}
|
||
|
||
{:error, reason} ->
|
||
{:error, "Could not find #{input}: #{reason}"}
|
||
end
|
||
end
|
||
end
|
||
|
||
defp coordinate_pair?(input) do
|
||
case String.split(input, ",", parts: 2) do
|
||
[a, b] ->
|
||
match?({_, _}, Float.parse(String.trim(a))) and match?({_, _}, Float.parse(String.trim(b)))
|
||
|
||
_ ->
|
||
false
|
||
end
|
||
end
|
||
|
||
defp maidenhead?(input) do
|
||
String.length(input) >= 4 and String.length(input) <= 10 and
|
||
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
|
||
end
|
||
|
||
defp build_scoring([], _src, _now, _band_config), do: {nil, nil}
|
||
|
||
defp build_scoring(profiles, src, now, band_config) 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,
|
||
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))
|
||
}
|
||
|
||
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
|
||
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
|
||
|
||
defp parse_float(nil, default), do: default
|
||
defp parse_float("", default), do: default
|
||
|
||
defp parse_float(str, default) do
|
||
case Float.parse(str) do
|
||
{val, _} -> val
|
||
:error -> default
|
||
end
|
||
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"
|
||
defp tier_label(score) when score >= 65, do: "GOOD"
|
||
defp tier_label(score) when score >= 50, do: "MARGINAL"
|
||
defp tier_label(score) when score >= 33, do: "POOR"
|
||
defp tier_label(_), do: "NEGLIGIBLE"
|
||
|
||
defp tier_color(score) when score >= 80, do: "#059669"
|
||
defp tier_color(score) when score >= 65, do: "#0d9488"
|
||
defp tier_color(score) when score >= 50, do: "#ca8a04"
|
||
defp tier_color(score) when score >= 33, do: "#ea580c"
|
||
defp tier_color(_), do: "#dc2626"
|
||
|
||
defp terrain_verdict_class("CLEAR"), do: "badge badge-success"
|
||
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge badge-warning"
|
||
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge badge-warning"
|
||
defp terrain_verdict_class("BLOCKED"), do: "badge badge-error"
|
||
defp terrain_verdict_class(_), do: "badge"
|
||
|
||
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
|
||
defp format_number(n), do: to_string(n)
|
||
|
||
defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
|
||
defp format_utc_hour(_), do: "?"
|
||
|
||
defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W"
|
||
defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
|
||
defp format_mw(mw), do: "#{format_number(mw * 1000)} uW"
|
||
|
||
# ── Render ──
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||
<.header>
|
||
Path Calculator
|
||
<:subtitle>Analyze microwave propagation between two points</:subtitle>
|
||
</.header>
|
||
|
||
<form phx-submit="calculate" phx-change="update_form" class="bg-base-200 rounded-box p-4 mb-6">
|
||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">Source</label>
|
||
<div class="flex gap-1">
|
||
<input
|
||
type="text"
|
||
name="source"
|
||
id="source-input"
|
||
value={@source}
|
||
placeholder="W5ISP, EM13sf, or lat,lon"
|
||
class="input input-bordered flex-1"
|
||
/>
|
||
<button
|
||
type="button"
|
||
id="locate-me"
|
||
phx-hook="LocateMe"
|
||
class="btn btn-sm btn-outline gap-1"
|
||
title="Use device GPS"
|
||
>
|
||
<.icon name="hero-map-pin" class="size-3.5" /> My Location
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">
|
||
Destination (<span
|
||
class="tooltip tooltip-top underline decoration-dotted cursor-help"
|
||
data-tip="Callsign lookup queries QRZ for the licensee's mailing address, then geocodes that address — so the position may be off from where the station actually operates."
|
||
>callsign</span> or grid)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="destination"
|
||
value={@destination}
|
||
placeholder="K5TR or EM00cd"
|
||
class="input input-bordered w-full"
|
||
required
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">Band</label>
|
||
<select name="band" class="select select-bordered w-full">
|
||
<%= for {label, value} <- @band_options do %>
|
||
<option value={value} selected={value == @band}>{label}</option>
|
||
<% end %>
|
||
</select>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">TX Power (dBm)</label>
|
||
<input
|
||
type="number"
|
||
name="tx_power_dbm"
|
||
value={@tx_power_dbm}
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 items-end mt-3">
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">TX Height (ft)</label>
|
||
<input
|
||
type="number"
|
||
name="src_height_ft"
|
||
value={@src_height_ft}
|
||
min="0"
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">TX Gain (dBi)</label>
|
||
<input
|
||
type="number"
|
||
name="src_gain_dbi"
|
||
value={@src_gain_dbi}
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">RX Height (ft)</label>
|
||
<input
|
||
type="number"
|
||
name="dst_height_ft"
|
||
value={@dst_height_ft}
|
||
min="0"
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">RX Gain (dBi)</label>
|
||
<input
|
||
type="number"
|
||
name="dst_gain_dbi"
|
||
value={@dst_gain_dbi}
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4">
|
||
<button type="submit" class="btn btn-primary" disabled={@computing}>
|
||
<%= if @computing do %>
|
||
<span class="loading loading-spinner loading-sm"></span> Computing...
|
||
<% else %>
|
||
Calculate Path
|
||
<% end %>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<%= if @error do %>
|
||
<div class="alert alert-error mb-6">{@error}</div>
|
||
<% end %>
|
||
|
||
<%= if @result do %>
|
||
<.path_results result={@result} />
|
||
<% end %>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
|
||
defp path_results(assigns) do
|
||
~H"""
|
||
<%!-- Map --%>
|
||
<div
|
||
id="path-map"
|
||
phx-hook="ContactMap"
|
||
phx-update="ignore"
|
||
data-pos1={Jason.encode!(%{lat: @result.source.lat, lon: @result.source.lon})}
|
||
data-pos2={Jason.encode!(%{lat: @result.destination.lat, lon: @result.destination.lon})}
|
||
data-station1={@result.source.label}
|
||
data-station2={@result.destination.label}
|
||
class="w-full h-64 md:h-80 rounded-box overflow-hidden mb-4"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Terrain profile --%>
|
||
<%= if @result.terrain do %>
|
||
<div
|
||
id="path-elevation"
|
||
phx-hook="ElevationProfile"
|
||
phx-update="ignore"
|
||
data-profile={Jason.encode!(elevation_data(@result))}
|
||
class="w-full h-48 md:h-56 rounded-box overflow-hidden mb-6 bg-base-200 p-2"
|
||
>
|
||
<canvas></canvas>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%!-- Link Summary --%>
|
||
<div class="bg-base-200 rounded-box p-4 mb-4">
|
||
<div class="text-xs opacity-60 mb-2">
|
||
Link Summary · {@result.band_config.label}
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
|
||
<div>
|
||
<span class="opacity-60">Distance</span>
|
||
<div class="font-mono text-lg">{format_number(@result.dist_km)} km</div>
|
||
</div>
|
||
<div>
|
||
<span class="opacity-60">Bearing</span>
|
||
<div class="font-mono text-lg">{format_number(@result.bearing)}°</div>
|
||
</div>
|
||
<%= if @result.terrain do %>
|
||
<div>
|
||
<span class="opacity-60">Terrain</span>
|
||
<div>
|
||
<span class={terrain_verdict_class(@result.terrain.analysis.verdict)}>
|
||
{@result.terrain.analysis.verdict}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
<%= if @result.scoring do %>
|
||
<div>
|
||
<span class="opacity-60">Propagation</span>
|
||
<div class="font-mono text-lg" style={"color: #{tier_color(@result.scoring.score)}"}>
|
||
{@result.scoring.score}/100
|
||
</div>
|
||
<div class="text-xs" style={"color: #{tier_color(@result.scoring.score)}"}>
|
||
{tier_label(@result.scoring.score)}
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
<div>
|
||
<span class="opacity-60">HRRR Data</span>
|
||
<div class="font-mono">{@result.hrrr_count} / 3 points</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<%!-- Loss Budget --%>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-2">
|
||
Loss Budget · {@result.band_config.label}
|
||
</div>
|
||
<div class="space-y-1 text-sm">
|
||
<.budget_row label="FSPL (free-space)" value={@result.loss_budget.fspl} unit="dB" />
|
||
<.budget_row
|
||
label="Gaseous (O\u2082+H\u2082O)"
|
||
value={Float.round(@result.loss_budget.o2 + @result.loss_budget.h2o, 2)}
|
||
unit="dB"
|
||
/>
|
||
<%= if @result.loss_budget.diffraction > 0 do %>
|
||
<.budget_row
|
||
label="Diffraction loss"
|
||
value={@result.loss_budget.diffraction}
|
||
unit="dB"
|
||
highlight
|
||
/>
|
||
<% end %>
|
||
<%= if @result.loss_budget.rain > 0 do %>
|
||
<.budget_row label="Rain attenuation" value={@result.loss_budget.rain} unit="dB" />
|
||
<% end %>
|
||
<div class="divider my-1"></div>
|
||
<.budget_row
|
||
label="TOTAL PATH LOSS"
|
||
value={@result.loss_budget.total}
|
||
unit="dB"
|
||
bold
|
||
/>
|
||
</div>
|
||
<div class="mt-3 text-xs opacity-50 space-y-0.5">
|
||
<div>
|
||
Gas rate: {format_number(
|
||
Float.round(
|
||
(@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001),
|
||
4
|
||
)
|
||
)} dB/km
|
||
</div>
|
||
<div>
|
||
H₂O: {format_number(@result.band_config.h2o_coeff)} dB/km/g ·
|
||
O₂: {format_number(@result.band_config.o2_db_km)} dB/km
|
||
</div>
|
||
<%= if @result.conditions do %>
|
||
<div>Abs humidity avg: {format_number(@result.conditions.abs_humidity)} g/m³</div>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Power Budget --%>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-2">Power Budget</div>
|
||
<div class="space-y-1 text-sm">
|
||
<.budget_row
|
||
label="TX Power"
|
||
value={@result.power_budget.tx_power_dbm}
|
||
unit="dBm"
|
||
note={"(#{format_mw(@result.station_params.tx_power_mw)})"}
|
||
/>
|
||
<.budget_row
|
||
label="TX Ant Gain"
|
||
value={@result.station_params.src_gain_dbi}
|
||
unit="dBi"
|
||
positive
|
||
/>
|
||
<.budget_row label="Feed Loss (×2)" value={-2.0} unit="dB" />
|
||
<div class="divider my-1"></div>
|
||
<.budget_row label="EIRP" value={@result.power_budget.eirp_dbm - 2.0} unit="dBm" bold />
|
||
<.budget_row
|
||
label="Path Loss (w/FF)"
|
||
value={-@result.loss_budget.total}
|
||
unit="dB"
|
||
highlight
|
||
/>
|
||
<.budget_row
|
||
label="RX Ant Gain"
|
||
value={@result.station_params.dst_gain_dbi}
|
||
unit="dBi"
|
||
positive
|
||
/>
|
||
<div class="divider my-1"></div>
|
||
<.budget_row
|
||
label="RX Power"
|
||
value={@result.power_budget.rx_power_dbm - 2.0}
|
||
unit="dBm"
|
||
bold
|
||
/>
|
||
<.budget_row label="Sensitivity (CW)" value={-141.0} unit="dBm" />
|
||
<div class="divider my-1"></div>
|
||
<% margin = @result.power_budget.margin_cw - 2.0 %>
|
||
<.budget_row
|
||
label="MARGIN"
|
||
value={Float.round(margin, 1)}
|
||
unit="dB"
|
||
bold
|
||
color={if margin > 0, do: "text-success", else: "text-error"}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<%!-- Path Weather --%>
|
||
<%= if @result.conditions do %>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-3">Path Weather Avg</div>
|
||
<div class="grid grid-cols-2 gap-x-6 gap-y-3">
|
||
<div>
|
||
<span class="text-xs opacity-60">Temperature</span>
|
||
<div class="font-mono text-xl">{format_number(@result.conditions.temp_f)}°F</div>
|
||
<div class="font-mono text-xs opacity-50">
|
||
{format_number(@result.conditions.temp_c)}°C
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Dewpoint</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.dewpoint_f)}°F
|
||
</div>
|
||
<div class="font-mono text-xs opacity-50">
|
||
{format_number(@result.conditions.dewpoint_c)}°C
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">T/Td Gap</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.temp_f - @result.conditions.dewpoint_f)}°F
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Abs Humidity</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.abs_humidity)} g/m³
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Pressure</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.pressure_mb || 0)} mb
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">PWAT</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.pwat_mm || 0)} mm
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">BL Depth</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.bl_depth_m || 0)} m
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Refrac Gradient</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.min_refractivity_gradient || 0)}
|
||
</div>
|
||
<div class="text-xs opacity-50">N/km</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%!-- Propagation Factors --%>
|
||
<%= if @result.scoring do %>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-3">
|
||
Propagation Factors · {@result.band_config.label}
|
||
</div>
|
||
<.factor_bars scoring={@result.scoring} />
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
|
||
<%!-- 18-Hour Forecast --%>
|
||
<%= if @result.forecast != [] do %>
|
||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
||
<div class="text-xs opacity-60 mb-3">
|
||
18-Hour Propagation Forecast · {@result.band_config.label}
|
||
</div>
|
||
<div class="flex items-end gap-0.5 h-24">
|
||
<%= for point <- @result.forecast do %>
|
||
<% pct = max(point.score, 2) %>
|
||
<div
|
||
class="flex-1 rounded-t-sm relative group cursor-default"
|
||
style={"height: #{pct}%; background-color: #{tier_color(point.score)}; min-width: 4px"}
|
||
title={"#{format_utc_hour(point.valid_time)} UTC: #{point.score}/100 #{tier_label(point.score)}"}
|
||
>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
<div class="flex justify-between text-xs opacity-50 mt-1">
|
||
<%= if length(@result.forecast) > 0 do %>
|
||
<span>{format_utc_hour(List.first(@result.forecast).valid_time)} UTC</span>
|
||
<span>{format_utc_hour(List.last(@result.forecast).valid_time)} UTC</span>
|
||
<% end %>
|
||
</div>
|
||
<div class="flex justify-between text-xs mt-2">
|
||
<% best = Enum.max_by(@result.forecast, & &1.score) %>
|
||
<span>
|
||
Best:
|
||
<span class="font-bold" style={"color: #{tier_color(best.score)}"}>
|
||
{best.score}
|
||
</span>
|
||
at {format_utc_hour(best.valid_time)} UTC
|
||
</span>
|
||
<% worst = Enum.min_by(@result.forecast, & &1.score) %>
|
||
<span>
|
||
Worst:
|
||
<span class="font-bold" style={"color: #{tier_color(worst.score)}"}>
|
||
{worst.score}
|
||
</span>
|
||
at {format_utc_hour(worst.valid_time)} UTC
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%!-- Share link --%>
|
||
<div class="mb-6">
|
||
<button
|
||
type="button"
|
||
phx-hook="CopyLink"
|
||
id="copy-link"
|
||
class="btn btn-sm btn-outline gap-1"
|
||
>
|
||
<.icon name="hero-link" class="size-3.5" /> Copy Link
|
||
</button>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
attr :label, :string, required: true
|
||
attr :value, :any, required: true
|
||
attr :unit, :string, required: true
|
||
attr :note, :string, default: nil
|
||
attr :bold, :boolean, default: false
|
||
attr :highlight, :boolean, default: false
|
||
attr :positive, :boolean, default: false
|
||
attr :color, :string, default: nil
|
||
|
||
defp budget_row(assigns) do
|
||
~H"""
|
||
<div class={["flex justify-between", @bold && "font-bold", @color]}>
|
||
<span class={[@highlight && "text-warning"]}>{@label}</span>
|
||
<span class="font-mono">
|
||
<span class={[@positive && "text-success", @highlight && "text-warning"]}>
|
||
{format_value(@value)}
|
||
</span>
|
||
<span class="opacity-60 text-xs">{@unit}</span>
|
||
<%= if @note do %>
|
||
<span class="opacity-50 text-xs ml-1">{@note}</span>
|
||
<% end %>
|
||
</span>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp format_value(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1)
|
||
defp format_value(v), do: to_string(v)
|
||
|
||
defp factor_bars(assigns) do
|
||
weights = BandConfig.weights()
|
||
|
||
rows =
|
||
assigns.scoring.factors
|
||
|> Enum.map(fn {key, score} ->
|
||
%{name: factor_name(key), score: score, weight: Map.get(weights, key, 0)}
|
||
end)
|
||
|> Enum.sort_by(& &1.score, :desc)
|
||
|
||
assigns = assign(assigns, :rows, rows)
|
||
|
||
~H"""
|
||
<div class="space-y-2">
|
||
<%= for row <- @rows do %>
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-0.5">
|
||
<span class="opacity-70">{row.name}</span>
|
||
<span class="font-mono font-bold">{row.score}</span>
|
||
</div>
|
||
<div class="w-full bg-base-300 rounded-full h-2">
|
||
<div
|
||
class="h-2 rounded-full"
|
||
style={"width: #{row.score}%; background-color: #{score_bar_color(row.score)}"}
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp score_bar_color(s) when s >= 80, do: "#059669"
|
||
defp score_bar_color(s) when s >= 65, do: "#0d9488"
|
||
defp score_bar_color(s) when s >= 50, do: "#ca8a04"
|
||
defp score_bar_color(s) when s >= 33, do: "#ea580c"
|
||
defp score_bar_color(_), do: "#dc2626"
|
||
|
||
defp factor_name(:humidity), do: "Humidity"
|
||
defp factor_name(:time_of_day), do: "Time of Day"
|
||
defp factor_name(:td_depression), do: "T-Td Depression"
|
||
defp factor_name(:refractivity), do: "Refractivity"
|
||
defp factor_name(:sky), do: "Sky Cover"
|
||
defp factor_name(:season), do: "Season"
|
||
defp factor_name(:wind), do: "Wind"
|
||
defp factor_name(:rain), do: "Rain"
|
||
defp factor_name(:pwat), do: "PWAT"
|
||
defp factor_name(:pressure), do: "Pressure"
|
||
|
||
defp elevation_data(result) do
|
||
# ElevationProfile hook expects: points with dist_km, elev, beam, r1
|
||
points =
|
||
Enum.map(result.terrain.analysis.points, fn p ->
|
||
%{
|
||
dist_km: p.dist_km,
|
||
elev: p.elev,
|
||
beam: p[:beam] || 0,
|
||
r1: p[:r1] || 0
|
||
}
|
||
end)
|
||
|
||
%{
|
||
points: points,
|
||
freq_mhz: result.band_mhz,
|
||
dist_km: result.dist_km,
|
||
verdict: result.terrain.analysis.verdict,
|
||
ducts: []
|
||
}
|
||
end
|
||
end
|