prop/lib/microwaveprop_web/live/path_live.ex
Graham McIntire 263c1bbdf5
Fix /path crash on clear-terrain diffraction loss
TerrainAnalysis.deygout_diffraction/2 short-circuits to integer 0
when the path has no interior points or the principal edge nu is
below the shadow boundary, which then made Float.round/2 blow up
inside compute_loss_budget when the rest of the losses were all
floats. Multiply the diffraction value by 1.0 before it feeds into
the total so clear paths coerce cleanly without touching
TerrainAnalysis's (number()) contract.
2026-04-12 17:50:04 -05:00

1155 lines
37 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# 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
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 &middot; {@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)}&deg;</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 &middot; {@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&#8322;O: {format_number(@result.band_config.h2o_coeff)} dB/km/g &middot;
O&#8322;: {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&sup3;</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)}&deg;F</div>
<div class="font-mono text-xs opacity-50">
{format_number(@result.conditions.temp_c)}&deg;C
</div>
</div>
<div>
<span class="text-xs opacity-60">Dewpoint</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.dewpoint_f)}&deg;F
</div>
<div class="font-mono text-xs opacity-50">
{format_number(@result.conditions.dewpoint_c)}&deg;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)}&deg;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&sup3;
</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)} 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 &middot; {@result.band_config.label}
</div>
<.factor_bars scoring={@result.scoring} />
</div>
<% end %>
</div>
<%!-- 18-Hour Forecast --%>
<%= if @result.forecast != [] do %>
<.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
<% 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 :forecast, :list, required: true
attr :band_config, :map, required: true
defp forecast_chart(assigns) do
points = build_forecast_points(assigns.forecast)
assigns =
assigns
|> assign(:points, points)
|> assign(:polyline, Enum.map_join(points, " ", fn p -> "#{p.x},#{p.y}" end))
|> assign(:now_pt, Enum.find(points, & &1.now?) || hd(points))
|> assign(:best_pt, Enum.max_by(points, & &1.score))
|> assign(:worst_pt, Enum.min_by(points, & &1.score))
|> assign(:trend, forecast_trend(points))
|> assign(:x_labels, forecast_x_labels(points))
~H"""
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="flex items-center justify-between mb-2">
<div class="text-xs opacity-60">
{length(@forecast)}-Hour Propagation Forecast &middot; {@band_config.label}
</div>
<div class="text-xs font-bold">
{Phoenix.HTML.raw(@trend)}
</div>
</div>
<svg viewBox="0 0 300 96" class="w-full h-24">
<%= for s <- [0, 50, 100] do %>
<% y = 6 + (100 - s) / 100 * 70 %>
<line
x1="28"
y1={y}
x2="294"
y2={y}
stroke="currentColor"
class="opacity-10"
stroke-width="1"
/>
<text
x="24"
y={y + 3}
font-size="9"
text-anchor="end"
fill="currentColor"
class="opacity-40"
>
{s}
</text>
<% end %>
<polyline
points={@polyline}
fill="none"
stroke={tier_color(@best_pt.score)}
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
<%= for pt <- @points do %>
<circle cx={pt.x} cy={pt.y} r="2" fill={tier_color(pt.score)} />
<% end %>
<circle
cx={@now_pt.x}
cy={@now_pt.y}
r="4"
fill="white"
stroke={tier_color(@now_pt.score)}
stroke-width="2"
/>
<circle
cx={@best_pt.x}
cy={@best_pt.y}
r="4"
fill={tier_color(@best_pt.score)}
stroke="white"
stroke-width="1.5"
/>
<text
x={@best_pt.x}
y={@best_pt.y - 6}
font-size="9"
text-anchor="middle"
font-weight="700"
fill={tier_color(@best_pt.score)}
>
{@best_pt.score}
</text>
<%= for {x, label} <- @x_labels do %>
<text
x={x}
y="92"
font-size="9"
text-anchor="middle"
fill="currentColor"
class="opacity-40"
>
{label}
</text>
<% end %>
</svg>
<div class="flex justify-between text-xs mt-2">
<span>
Best:
<span class="font-bold" style={"color: #{tier_color(@best_pt.score)}"}>
{@best_pt.score}
</span>
at {@best_pt.label} UTC
</span>
<span>
Worst:
<span class="font-bold" style={"color: #{tier_color(@worst_pt.score)}"}>
{@worst_pt.score}
</span>
at {@worst_pt.label} UTC
</span>
</div>
</div>
"""
end
defp build_forecast_points(forecast) do
n = length(forecast)
now = DateTime.utc_now()
# Map each forecast point to SVG coords (viewBox 0 0 300 96)
# plot area: x in [28, 294], y in [6, 76]
indexed = Enum.with_index(forecast)
now_idx =
indexed
|> Enum.min_by(fn {p, _i} -> abs(DateTime.diff(p.valid_time, now, :second)) end)
|> elem(1)
Enum.map(indexed, fn {p, i} ->
x =
if n <= 1 do
161
else
28 + i / (n - 1) * 266
end
y = 6 + (100 - p.score) / 100 * 70
%{
x: Float.round(x * 1.0, 2),
y: Float.round(y * 1.0, 2),
score: p.score,
valid_time: p.valid_time,
label: format_utc_hour(p.valid_time),
now?: i == now_idx
}
end)
end
defp forecast_trend(points) do
now_idx = Enum.find_index(points, & &1.now?) || 0
future = Enum.drop(points, now_idx)
case future do
[first | _] ->
last = List.last(future)
diff = last.score - first.score
cond do
diff > 5 -> ~s(<span class="text-success">▲ Improving</span>)
diff < -5 -> ~s(<span class="text-error">▼ Declining</span>)
true -> ~s(<span class="text-warning">→ Steady</span>)
end
_ ->
~s(<span class="opacity-50">→ Steady</span>)
end
end
defp forecast_x_labels(points) do
n = length(points)
indices =
if n <= 3 do
Enum.to_list(0..(n - 1))
else
[0, div(n - 1, 2), n - 1]
end
Enum.map(indices, fn i ->
p = Enum.at(points, i)
{p.x, p.label}
end)
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