prop/lib/microwaveprop_web/live/path_live.ex
Graham McIntire c5fdef2675 Add station parameters, GPS locate, and detailed budgets to /path
- TX/RX height in feet (converted to meters for terrain analysis)
- TX power in dBm with mW equivalent display
- TX gain and RX gain (dBi) inputs
- Power budget: TX power + TX gain - feed loss = EIRP - path loss
  + RX gain = RX power vs CW sensitivity = margin
- Loss budget: FSPL + gaseous (O2+H2O) + diffraction + rain
  with per-km gas rate detail
- GPS "Locate Me" button using browser geolocation API
- Lat,lon coordinate pairs accepted as input
- Weather displayed in F with C below
- LiveStash preserves form inputs across page reloads
- Terrain cross-section with beam line and Fresnel zone
2026-04-07 13:39:02 -05:00

886 lines
29 KiB
Elixir

defmodule MicrowavepropWeb.PathLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveStash
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 [
{"10 GHz", "10000"},
{"24 GHz", "24000"},
{"47 GHz", "47000"},
{"68 GHz", "68000"},
{"76 GHz", "75000"},
{"122 GHz", "122000"},
{"134 GHz", "134000"},
{"241 GHz", "241000"}
]
@stash_fields [
:source,
:destination,
:band,
:src_height_ft,
:dst_height_ft,
:tx_power_dbm,
:src_gain_dbi,
:dst_gain_dbi
]
@impl true
def mount(_params, _session, socket) do
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"
}
recovered =
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
socket.assigns
|> Map.take(@stash_fields)
|> then(&Map.merge(defaults, &1))
_ ->
defaults
end
{:ok,
assign(
socket,
[
{:page_title, "Path Calculator"},
{:band_options, @band_options},
{:result, nil},
{:error, nil},
{:computing, false}
] ++
Map.to_list(recovered)
)}
end
@impl true
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}
)
{:noreply, LiveStash.stash_assigns(socket, @stash_fields)}
end
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
# Use coordinate string as source — resolve_location handles lat,lon format
source = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"
{:noreply, assign(socket, source: source)}
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)
{: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,
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
# ── Geo helpers ──
defp haversine_km(lat1, lon1, lat2, lon2) do
r = 6371.0
dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1)
a = :math.sin(dlat / 2) ** 2 + :math.cos(deg_to_rad(lat1)) * :math.cos(deg_to_rad(lat2)) * :math.sin(dlon / 2) ** 2
2 * r * :math.asin(:math.sqrt(a))
end
defp bearing_deg(lat1, lon1, lat2, lon2) do
lat1r = deg_to_rad(lat1)
lat2r = deg_to_rad(lat2)
dlonr = deg_to_rad(lon2 - lon1)
y = :math.sin(dlonr) * :math.cos(lat2r)
x = :math.cos(lat1r) * :math.sin(lat2r) - :math.sin(lat1r) * :math.cos(lat2r) * :math.cos(dlonr)
b = :math.atan2(y, x) * 180 / :math.pi()
Float.round(:math.fmod(b + 360, 360), 1)
end
defp deg_to_rad(d), do: d * :math.pi() / 180
# ── 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: "#00ffa3"
defp tier_color(score) when score >= 65, do: "#7dffd4"
defp tier_color(score) when score >= 50, do: "#ffe566"
defp tier_color(score) when score >= 33, do: "#ff9044"
defp tier_color(_), do: "#ff4f4f"
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_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} max_width="max-w-6xl">
<.header>
Path Calculator
<:subtitle>Analyze microwave propagation between two points</:subtitle>
</.header>
<form phx-submit="calculate" 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 (callsign or grid)</label>
<div class="flex gap-1">
<input
type="text"
name="source"
id="source-input"
value={@source}
placeholder="W5ISP or EM13sf"
class="input input-bordered flex-1"
required
/>
<button
type="button"
id="locate-me"
phx-hook="LocateMe"
class="btn btn-square btn-sm btn-ghost"
title="Use GPS location"
>
<.icon name="hero-map-pin" class="size-4" />
</button>
</div>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">Destination (callsign 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 (\u00d72)" 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)}
</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 &middot; {@result.band_config.label}
</div>
<.factor_bars scoring={@result.scoring} />
</div>
<% end %>
</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: "#00ffa3"
defp score_bar_color(s) when s >= 65, do: "#7dffd4"
defp score_bar_color(s) when s >= 50, do: "#ffe566"
defp score_bar_color(s) when s >= 33, do: "#ff9044"
defp score_bar_color(_), do: "#ff4f4f"
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