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
This commit is contained in:
parent
c1e6d924eb
commit
c5fdef2675
3 changed files with 518 additions and 193 deletions
|
|
@ -35,6 +35,7 @@ import {ContactMap} from "./contact_map_hook"
|
|||
import {ContactsMap} from "./contacts_map_hook"
|
||||
import {ElevationProfile} from "./elevation_profile_hook"
|
||||
import {WeatherMap} from "./weather_map_hook"
|
||||
import {LocateMe} from "./locate_me_hook"
|
||||
|
||||
const UtcClock = {
|
||||
mounted() {
|
||||
|
|
@ -56,7 +57,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: initLiveStash({_csrf_token: csrfToken}),
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap},
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
27
assets/js/locate_me_hook.js
Normal file
27
assets/js/locate_me_hook.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export const LocateMe = {
|
||||
mounted() {
|
||||
this.el.addEventListener("click", () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert("Geolocation is not supported by this browser")
|
||||
return
|
||||
}
|
||||
|
||||
this.el.classList.add("loading", "loading-spinner")
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
this.el.classList.remove("loading", "loading-spinner")
|
||||
this.pushEvent("gps_location", {
|
||||
lat: pos.coords.latitude,
|
||||
lon: pos.coords.longitude
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
this.el.classList.remove("loading", "loading-spinner")
|
||||
alert("Could not get location: " + err.message)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule MicrowavepropWeb.PathLive do
|
||||
@moduledoc false
|
||||
use MicrowavepropWeb, :live_view
|
||||
use LiveStash
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Propagation.Scorer
|
||||
|
|
@ -21,33 +22,106 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
{"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,
|
||||
source: "",
|
||||
destination: "",
|
||||
band: "10000",
|
||||
result: nil,
|
||||
error: nil,
|
||||
computing: false
|
||||
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", %{"source" => source, "destination" => dest, "band" => band}, socket) do
|
||||
socket = assign(socket, source: source, destination: dest, band: band, error: nil, computing: true, result: nil)
|
||||
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)
|
||||
|
||||
send(self(), {:compute_path, source, dest, String.to_integer(band)})
|
||||
tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0)
|
||||
tx_power_mw = :math.pow(10, tx_power_dbm / 10)
|
||||
|
||||
{:noreply, socket}
|
||||
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}, socket) do
|
||||
case compute_path(source, dest, band_mhz) do
|
||||
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)}
|
||||
|
||||
|
|
@ -56,7 +130,7 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp compute_path(source, dest, band_mhz) do
|
||||
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)
|
||||
|
|
@ -65,11 +139,16 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
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
|
||||
# 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)
|
||||
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, _} ->
|
||||
|
|
@ -89,13 +168,15 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
# Build conditions and score
|
||||
{conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config)
|
||||
|
||||
# Loss budget
|
||||
# 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,
|
||||
|
|
@ -105,6 +186,7 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
conditions: conditions,
|
||||
scoring: scoring,
|
||||
loss_budget: loss_budget,
|
||||
power_budget: power_budget,
|
||||
hrrr_count: length(hrrr_profiles)
|
||||
}}
|
||||
end
|
||||
|
|
@ -117,6 +199,12 @@ defmodule MicrowavepropWeb.PathLive 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}}
|
||||
|
|
@ -135,6 +223,16 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
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)
|
||||
|
|
@ -225,6 +323,37 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
}
|
||||
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
|
||||
|
|
@ -270,6 +399,10 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
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
|
||||
|
|
@ -285,14 +418,26 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
<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>
|
||||
<input
|
||||
type="text"
|
||||
name="source"
|
||||
value={@source}
|
||||
placeholder="W5ISP or EM13sf"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
|
@ -313,11 +458,67 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
<% 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
|
||||
Calculate Path
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -356,205 +557,301 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
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"
|
||||
class="w-full h-48 md:h-56 rounded-box overflow-hidden mb-6 bg-base-200 p-2"
|
||||
>
|
||||
<canvas></canvas>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Summary cards --%>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<%!-- Link Summary --%>
|
||||
<div class="bg-base-200 rounded-box p-4">
|
||||
<div class="text-xs opacity-60 mb-2">Link Summary</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>Distance</span>
|
||||
<span class="font-mono">{format_number(@result.dist_km)} km</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Bearing</span>
|
||||
<span class="font-mono">{format_number(@result.bearing)}°</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Band</span>
|
||||
<span class="font-mono">{@result.band_config.label}</span>
|
||||
</div>
|
||||
<%= if @result.terrain do %>
|
||||
<div class="flex justify-between">
|
||||
<span>Terrain</span>
|
||||
<%!-- 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>
|
||||
<% end %>
|
||||
<div class="flex justify-between">
|
||||
<span>HRRR Profiles</span>
|
||||
<span class="font-mono">{@result.hrrr_count} / 3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Loss Budget --%>
|
||||
<div class="bg-base-200 rounded-box p-4">
|
||||
<div class="text-xs opacity-60 mb-2">Loss Budget</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>Free Space</span>
|
||||
<span class="font-mono">{@result.loss_budget.fspl} dB</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>O₂ Absorption</span>
|
||||
<span class="font-mono">{@result.loss_budget.o2} dB</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>H₂O Absorption</span>
|
||||
<span class="font-mono">{@result.loss_budget.h2o} dB</span>
|
||||
</div>
|
||||
<%= if @result.loss_budget.rain > 0 do %>
|
||||
<div class="flex justify-between">
|
||||
<span>Rain</span>
|
||||
<span class="font-mono">{@result.loss_budget.rain} dB</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @result.loss_budget.diffraction > 0 do %>
|
||||
<div class="flex justify-between">
|
||||
<span>Diffraction</span>
|
||||
<span class="font-mono">{@result.loss_budget.diffraction} dB</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="divider my-1"></div>
|
||||
<div class="flex justify-between font-bold">
|
||||
<span>Total Path Loss</span>
|
||||
<span class="font-mono">{@result.loss_budget.total} dB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Propagation Score --%>
|
||||
<div class="bg-base-200 rounded-box p-4">
|
||||
<div class="text-xs opacity-60 mb-2">Propagation Score</div>
|
||||
<% end %>
|
||||
<%= if @result.scoring do %>
|
||||
<div class="text-center mb-3">
|
||||
<span
|
||||
class="text-4xl font-bold"
|
||||
style={"color: #{tier_color(@result.scoring.score)}"}
|
||||
>
|
||||
{@result.scoring.score}
|
||||
</span>
|
||||
<span class="text-sm opacity-60"> / 100</span>
|
||||
<div
|
||||
class="text-sm font-semibold mt-1"
|
||||
style={"color: #{tier_color(@result.scoring.score)}"}
|
||||
>
|
||||
<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>
|
||||
<% else %>
|
||||
<div class="text-center text-sm opacity-60 py-4">
|
||||
No HRRR data available for current conditions
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<span class="opacity-60">HRRR Data</span>
|
||||
<div class="font-mono">{@result.hrrr_count} / 3 points</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Factor breakdown --%>
|
||||
<%= if @result.scoring do %>
|
||||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
||||
<div class="text-xs opacity-60 mb-3">Propagation Factors (current conditions)</div>
|
||||
<.factor_table scoring={@result.scoring} />
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Atmospheric conditions --%>
|
||||
<%= if @result.conditions do %>
|
||||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
||||
<div class="text-xs opacity-60 mb-3">Path Atmospheric Conditions</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<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>
|
||||
<span class="opacity-60">Temperature</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.temp_c)}°C</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>
|
||||
<span class="opacity-60">Dewpoint</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.dewpoint_c)}°C</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60">T-Td Depression</span>
|
||||
<div class="font-mono">
|
||||
{format_number(@result.conditions.temp_c - @result.conditions.dewpoint_c)}°C
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60">Pressure</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.pressure_mb || 0)} mb</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60">PWAT</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.pwat_mm || 0)} mm</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60">BL Depth</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.bl_depth_m || 0)} m</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60">Refractivity Gradient</span>
|
||||
<div class="font-mono">
|
||||
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60">Abs Humidity</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.abs_humidity)} g/m³</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>
|
||||
<% end %>
|
||||
|
||||
<%!-- 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)}°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>
|
||||
"""
|
||||
end
|
||||
|
||||
defp factor_table(assigns) do
|
||||
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} ->
|
||||
weight = Map.get(weights, key, 0)
|
||||
|
||||
%{
|
||||
name: factor_name(key),
|
||||
score: score,
|
||||
weight: round(weight * 100),
|
||||
contribution: Float.round(score * weight, 1)
|
||||
}
|
||||
%{name: factor_name(key), score: score, weight: Map.get(weights, key, 0)}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.contribution, :desc)
|
||||
|> Enum.sort_by(& &1.score, :desc)
|
||||
|
||||
assigns = assign(assigns, :rows, rows)
|
||||
|
||||
~H"""
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Factor</th>
|
||||
<th class="text-right">Score</th>
|
||||
<th class="text-right">Weight</th>
|
||||
<th class="text-right">Contribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for row <- @rows do %>
|
||||
<tr>
|
||||
<td>{row.name}</td>
|
||||
<td class="text-right font-mono">{row.score}</td>
|
||||
<td class="text-right font-mono opacity-60">{row.weight}%</td>
|
||||
<td class="text-right font-mono font-bold">{row.contribution}</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<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"
|
||||
|
|
@ -567,14 +864,14 @@ defmodule MicrowavepropWeb.PathLive do
|
|||
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_m: p.elev,
|
||||
beam_m: p[:beam],
|
||||
fresnel_top: if(p[:beam] && p[:r1], do: p.beam + p.r1),
|
||||
fresnel_bottom: if(p[:beam] && p[:r1], do: p.beam - p.r1)
|
||||
elev: p.elev,
|
||||
beam: p[:beam] || 0,
|
||||
r1: p[:r1] || 0
|
||||
}
|
||||
end)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue