Add /path calculator page
LiveView page for analyzing microwave propagation between two points. Features: - Source/destination input: callsign (via gridmap.org API) or Maidenhead grid - Band selector for all 8 microwave bands (10-241 GHz) - Leaflet map showing path line (reuses ContactMap hook) - Terrain elevation cross-section (reuses ElevationProfile hook) - Link summary: distance, bearing, terrain verdict - Loss budget: FSPL + O2 + H2O + rain + diffraction - Propagation score with 10-factor breakdown - Current atmospheric conditions from path-integrated HRRR New CallsignClient for gridmap.org location lookup. Navigation links added to main navbar and map control panel.
This commit is contained in:
parent
577313a39e
commit
95351a2358
5 changed files with 631 additions and 0 deletions
37
lib/microwaveprop/radio/callsign_client.ex
Normal file
37
lib/microwaveprop/radio/callsign_client.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule Microwaveprop.Radio.CallsignClient do
|
||||
@moduledoc "Looks up amateur radio callsign locations via gridmap.org."
|
||||
|
||||
@doc """
|
||||
Fetch location for a callsign. Returns {:ok, %{callsign, gridsquare, lat, lon}} or {:error, reason}.
|
||||
"""
|
||||
def locate(callsign) do
|
||||
url = "https://gridmap.org/locate/#{URI.encode(callsign)}"
|
||||
|
||||
case Req.get(url, req_options() ++ [receive_timeout: 10_000]) do
|
||||
{:ok, %{status: 200, body: %{"latitude" => lat, "longitude" => lon} = body}} when is_number(lat) ->
|
||||
{:ok,
|
||||
%{
|
||||
callsign: body["callsign"] || callsign,
|
||||
gridsquare: body["gridsquare"],
|
||||
lat: lat,
|
||||
lon: lon
|
||||
}}
|
||||
|
||||
{:ok, %{status: 200, body: _}} ->
|
||||
{:error, "callsign not found"}
|
||||
|
||||
{:ok, %{status: 404}} ->
|
||||
{:error, "callsign not found"}
|
||||
|
||||
{:ok, %{status: status}} ->
|
||||
{:error, "gridmap.org HTTP #{status}"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, "gridmap.org failed: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp req_options do
|
||||
Application.get_env(:microwaveprop, :callsign_req_options, [])
|
||||
end
|
||||
end
|
||||
|
|
@ -41,6 +41,7 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
<a href="/" class="font-semibold">NTMS Propagation Prediction</a>
|
||||
<nav class="flex gap-2">
|
||||
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
||||
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
|
||||
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
|
||||
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
||||
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
||||
|
|
|
|||
|
|
@ -360,6 +360,9 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
|
||||
<%!-- Links --%>
|
||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
||||
Path Calculator
|
||||
</.link>
|
||||
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
||||
Weather Map
|
||||
</.link>
|
||||
|
|
|
|||
589
lib/microwaveprop_web/live/path_live.ex
Normal file
589
lib/microwaveprop_web/live/path_live.ex
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
defmodule MicrowavepropWeb.PathLive do
|
||||
@moduledoc false
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
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"}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Path Calculator",
|
||||
band_options: @band_options,
|
||||
source: "",
|
||||
destination: "",
|
||||
band: "10000",
|
||||
result: nil,
|
||||
error: nil,
|
||||
computing: false
|
||||
)}
|
||||
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)
|
||||
|
||||
send(self(), {:compute_path, source, dest, String.to_integer(band)})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:compute_path, source, dest, band_mhz}, socket) do
|
||||
case compute_path(source, dest, band_mhz) 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) 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
|
||||
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)
|
||||
%{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 budget
|
||||
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
source: src,
|
||||
destination: dst,
|
||||
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,
|
||||
hrrr_count: length(hrrr_profiles)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_location(input) do
|
||||
input = String.trim(input)
|
||||
|
||||
cond do
|
||||
input == "" ->
|
||||
{:error, "Location is required"}
|
||||
|
||||
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 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
|
||||
|
||||
# ── 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)
|
||||
|
||||
# ── 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>
|
||||
<input
|
||||
type="text"
|
||||
name="source"
|
||||
value={@source}
|
||||
placeholder="W5ISP or EM13sf"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
<button type="submit" class="btn btn-primary" disabled={@computing}>
|
||||
<%= if @computing do %>
|
||||
<span class="loading loading-spinner loading-sm"></span> Computing...
|
||||
<% else %>
|
||||
Calculate
|
||||
<% 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"
|
||||
>
|
||||
</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>
|
||||
<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>
|
||||
<%= 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)}"}
|
||||
>
|
||||
{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>
|
||||
</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>
|
||||
<span class="opacity-60">Temperature</span>
|
||||
<div class="font-mono">{format_number(@result.conditions.temp_c)}°C</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp factor_table(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)
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.contribution, :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>
|
||||
"""
|
||||
end
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
points: points,
|
||||
freq_mhz: result.band_mhz,
|
||||
dist_km: result.dist_km,
|
||||
verdict: result.terrain.analysis.verdict,
|
||||
ducts: []
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -36,6 +36,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/contacts", ContactLive.Index
|
||||
live "/contacts/map", ContactMapLive
|
||||
live "/contacts/:id", ContactLive.Show
|
||||
live "/path", PathLive
|
||||
live "/algo", AlgoLive
|
||||
live "/backfill", BackfillLive
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue