prop/lib/microwaveprop_web/live/eme_live.ex
Graham McIntire cb8445f329
fix: resolve 158/183 credo --strict warnings
- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg)
- Move 12+ nested alias/import/require to module top level
- Add phx-change/id attributes to 11 raw HTML <form> tags
- Remove 4 unused LiveView assigns (:bounds, :data_provider)
- Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts)
- Break 2 long lines (path_compute.ex:382)
- Strengthen weak test assertions (is_binary→byte_size, is_list→!=[])
- Replace Module.concat with Module.safe_concat (2 occurrences)
- Replace length/1 > 0 with list != [] (9 occurrences)
- Remove no-op assert true, fix no-assertion tests

Remaining: 24 socket.assigns introspection warnings (deliberate test
pattern for observable behavior testing), 1 formatter-resistant long
line, 3 app-code usage warnings.
2026-06-12 15:47:15 -05:00

567 lines
21 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.EmeLive do
@moduledoc """
`/eme` Earth-Moon-Earth path-loss + antenna-aim calculator.
Given a station's grid/callsign/coords, TX power, and antenna gain,
the page computes:
- Current Moon az / el from the station (real-time tick).
- Slant range to the Moon.
- Round-trip EME path loss at the operating band.
- Link-budget received power for a self-echo bounce.
- Indicative CW SNR in a narrow detection bandwidth.
The Moon readout ticks once per 10 s so operators can watch the
aim drift live. Moon az/el is computed with a low-precision
ephemeris (±0.3°) — well inside the beamwidth of any amateur EME
dish at the supported microwave bands.
"""
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
alias Microwaveprop.Moon
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Eme
@band_options BandConfig.band_options()
@tick_ms 10_000
@defaults %{
"source" => "",
"band" => "1296",
"tx_power_dbm" => "50",
"tx_gain_dbi" => "30",
"bandwidth_hz" => "100",
"t_sys_k" => "150"
}
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
{:ok, _tref} = :timer.send_interval(@tick_ms, self(), :tick)
:ok
else
:ok
end
{:ok,
assign(socket,
page_title: "EME Calculator",
band_options: @band_options,
source: "",
source_resolved: nil,
band: "1296",
tx_power_dbm: "50",
tx_gain_dbi: "30",
bandwidth_hz: "100",
t_sys_k: "150",
moon: nil,
link: nil,
error: nil,
now_utc: DateTime.utc_now()
)}
end
@impl true
def handle_params(params, _uri, socket) do
p = Map.merge(@defaults, Map.take(params, Map.keys(@defaults)))
socket =
assign(socket,
source: p["source"],
band: p["band"],
tx_power_dbm: p["tx_power_dbm"],
tx_gain_dbi: p["tx_gain_dbi"],
bandwidth_hz: p["bandwidth_hz"],
t_sys_k: p["t_sys_k"]
)
{:noreply, recompute(socket)}
end
@impl true
def handle_event("calculate", params, socket) do
url_params =
@defaults
|> Map.keys()
|> Enum.reduce(%{}, fn k, acc ->
v = params[k] || ""
if v == "" or v == @defaults[k], do: acc, else: Map.put(acc, k, v)
end)
{:noreply, push_patch(socket, to: ~p"/eme?#{url_params}", replace: true)}
end
@impl true
def handle_info(:tick, socket) do
{:noreply, socket |> assign(now_utc: DateTime.utc_now()) |> recompute_moon_only()}
end
# Recompute both the source resolution and the link budget (called on
# form submit / URL patch).
defp recompute(socket) do
case resolve_source(socket.assigns.source) do
{:ok, resolved} ->
socket
|> assign(source_resolved: resolved, error: nil)
|> recompute_moon_only()
{:error, reason} ->
assign(socket, source_resolved: nil, error: reason, moon: nil, link: nil)
:empty ->
assign(socket, source_resolved: nil, error: nil, moon: nil, link: nil)
end
end
# Refresh just the live-ticking numbers (moon pos + link budget).
# Called from the tick timer so a stale error state doesn't get
# stomped by a repeat source-resolution.
defp recompute_moon_only(%{assigns: %{source_resolved: nil}} = socket), do: socket
defp recompute_moon_only(socket) do
%{lat: lat, lon: lon} = socket.assigns.source_resolved
moon = Moon.observer_position(socket.assigns.now_utc, lat, lon)
band_mhz = parse_int(socket.assigns.band, 1_296)
tx_dbm = parse_float(socket.assigns.tx_power_dbm, 50.0)
tx_gain = parse_float(socket.assigns.tx_gain_dbi, 30.0)
bw = parse_float(socket.assigns.bandwidth_hz, 100.0)
tsys = parse_float(socket.assigns.t_sys_k, 150.0)
# Band-dependent lunar albedo feeds both the path-loss number
# and the per-line-item display below.
albedo = Eme.moon_albedo(band_mhz)
fspl_rt = Eme.fspl_round_trip_db(band_mhz, moon.distance_km)
moon_gain = Eme.moon_reflection_gain_db(band_mhz, albedo)
path_loss = fspl_rt - moon_gain
# Keep everything in dBm for display — EIRP(dBm) = TX(dBm) + G_tx(dBi).
eirp_dbm = tx_dbm + tx_gain
received_power = tx_dbm + tx_gain + tx_gain - path_loss
noise_floor = Eme.noise_floor_dbm(bw, tsys)
snr = received_power - noise_floor
# Self-echo Doppler (Earth → Moon → Earth) for the current
# geometry. Live-updates with the tick.
v_radial_km_s = Moon.radial_velocity_km_s(socket.assigns.now_utc, lat, lon)
doppler_hz = Eme.doppler_shift_hz(band_mhz, v_radial_km_s)
link = %{
# EIRP breakdown so the template can render "TX + gain = EIRP".
tx_power_dbm: tx_dbm,
tx_power_w: :math.pow(10.0, (tx_dbm - 30) / 10.0),
tx_gain_dbi: tx_gain,
eirp_dbm: eirp_dbm,
eirp_w: :math.pow(10.0, (eirp_dbm - 30) / 10.0),
# Path-loss decomposition: spreading vs moon-reflection gain.
fspl_round_trip_db: fspl_rt,
moon_albedo: albedo,
moon_reflection_gain_db: moon_gain,
path_loss_db: path_loss,
# Final link numbers.
received_power_dbm: received_power,
noise_floor_dbm: noise_floor,
snr_db: snr,
# Doppler (2× one-way for self-echo round-trip).
radial_velocity_km_s: v_radial_km_s,
doppler_hz: doppler_hz
}
socket
|> assign(moon: moon, link: link)
|> push_event("eme:update", %{
lat: lat,
lon: lon,
az: moon.azimuth,
el: moon.elevation,
moon_visible: moon.elevation >= 0.0
})
end
# --- Input parsing --------------------------------------------------
defp resolve_source(input), do: MicrowavepropWeb.LocationResolver.resolve(input)
# --- Render helpers -------------------------------------------------
#
# Note on UTF-8: these use Elixir's native :erlang.float_to_binary/2
# rather than :io_lib.format/2 because the latter treats each byte of
# an inline UTF-8 codepoint (like "°" = 0xC2 0xB0) as a separate
# Latin-1 character, producing mojibake (°) when the result is
# joined back into a binary.
defp format_deg(nil), do: ""
defp format_deg(v) when is_number(v) do
:erlang.float_to_binary(v * 1.0, decimals: 2) <> "°"
end
defp format_distance_km(nil), do: ""
defp format_distance_km(v) when is_number(v) do
# Integer km with thousands separators ("374,088 km").
v |> round() |> Microwaveprop.Format.number() |> Kernel.<>(" km")
end
defp format_db(nil), do: ""
defp format_db(v) when is_number(v), do: :erlang.float_to_binary(v * 1.0, decimals: 1) <> " dB"
defp format_dbm(nil), do: ""
defp format_dbm(v) when is_number(v) do
:erlang.float_to_binary(v * 1.0, decimals: 1) <> " dBm"
end
defp format_doppler(nil), do: ""
defp format_doppler(hz) when is_number(hz) do
abs_hz = abs(hz)
body =
cond do
abs_hz >= 1000.0 -> :erlang.float_to_binary(hz / 1000.0, decimals: 2) <> " kHz"
abs_hz >= 1.0 -> :erlang.float_to_binary(hz * 1.0, decimals: 0) <> " Hz"
true -> :erlang.float_to_binary(hz * 1.0, decimals: 2) <> " Hz"
end
# Prepend a + sign for positive (rising) shifts; `float_to_binary`
# already renders negatives with the leading character.
if hz > 0, do: "+" <> body, else: body
end
defp format_radial_velocity(nil), do: ""
defp format_radial_velocity(v_km_s) when is_number(v_km_s) do
# Display in m/s at the ~kHz-scale Doppler levels EME operators
# care about. Positive = receding.
m_s = v_km_s * 1000.0
suffix = " m/s"
body = :erlang.float_to_binary(m_s, decimals: 1)
if m_s > 0, do: "+" <> body <> suffix, else: body <> suffix
end
defp format_watts(nil), do: ""
defp format_watts(w) when is_number(w) and w >= 1000 do
:erlang.float_to_binary(w / 1000.0, decimals: 2) <> " kW"
end
defp format_watts(w) when is_number(w) and w >= 1 do
:erlang.float_to_binary(w * 1.0, decimals: 1) <> " W"
end
defp format_watts(w) when is_number(w) and w > 0 do
:erlang.float_to_binary(w * 1000.0, decimals: 2) <> " mW"
end
defp format_watts(_), do: ""
defp format_albedo(nil), do: ""
defp format_albedo(v) when is_number(v) do
:erlang.float_to_binary(v * 1.0, decimals: 3)
end
defp snr_badge_class(nil), do: "badge-ghost"
defp snr_badge_class(snr) when snr >= 10, do: "badge-success"
defp snr_badge_class(snr) when snr >= 0, do: "badge-warning"
defp snr_badge_class(_), do: "badge-error"
defp elevation_badge_class(nil), do: "badge-ghost"
defp elevation_badge_class(el) when el >= 5.0, do: "badge-success"
defp elevation_badge_class(el) when el >= 0.0, do: "badge-warning"
defp elevation_badge_class(_), do: "badge-error"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-5xl px-4 py-6 space-y-6">
<header>
<h1 class="text-2xl font-semibold">EME Calculator</h1>
<p class="opacity-70 text-sm">
Earth-Moon-Earth path loss, link budget, and real-time Moon az / el for antenna aiming.
Moon position updates every 10 seconds; refresh to force an immediate recompute.
</p>
</header>
<form
phx-submit="calculate"
phx-change="calculate"
id="eme-form"
class="bg-base-200 rounded-box p-4 grid grid-cols-1 md:grid-cols-3 gap-3"
>
<label class="form-control md:col-span-3">
<div class="label">
<span class="label-text">Station (callsign, grid, or "lat,lon")</span>
</div>
<input
type="text"
name="source"
value={@source}
placeholder="e.g. W5ISP / EM13ng / 32.9,-97.0"
class="input input-bordered"
autocomplete="off"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">Band (MHz)</span></div>
<select name="band" class="select select-bordered">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == @band}>{label}</option>
<% end %>
</select>
</label>
<label class="form-control">
<div class="label"><span class="label-text">TX power (dBm)</span></div>
<input
type="number"
name="tx_power_dbm"
value={@tx_power_dbm}
step="0.1"
class="input input-bordered"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">Antenna gain (dBi)</span></div>
<input
type="number"
name="tx_gain_dbi"
value={@tx_gain_dbi}
step="0.1"
class="input input-bordered"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">Detection BW (Hz)</span></div>
<input
type="number"
name="bandwidth_hz"
value={@bandwidth_hz}
step="1"
class="input input-bordered"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">System noise Tsys (K)</span></div>
<input
type="number"
name="t_sys_k"
value={@t_sys_k}
step="1"
class="input input-bordered"
/>
</label>
<div class="md:col-span-1 flex items-end">
<button type="submit" class="btn btn-primary w-full">Calculate</button>
</div>
</form>
<%= if @error do %>
<div class="alert alert-error">
<span>{@error}</span>
</div>
<% end %>
<%= if @source_resolved do %>
<section class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-base-100 border border-base-300 rounded-box p-4">
<div class="flex items-center justify-between gap-2">
<h2 class="text-base font-semibold">Antenna aim</h2>
<span class="text-xs opacity-60 font-mono whitespace-nowrap">
{Calendar.strftime(@now_utc, "%Y-%m-%d %H:%M:%SZ")}
</span>
</div>
<dl class="mt-3 text-sm space-y-1.5">
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Station</dt>
<dd class="font-mono text-right">{@source_resolved.label}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Lat / lon</dt>
<dd class="font-mono text-right">
{Float.round(@source_resolved.lat, 3)}, {Float.round(@source_resolved.lon, 3)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Azimuth</dt>
<dd class="font-mono text-right">{format_deg(@moon && @moon.azimuth)}</dd>
</div>
<div class="flex items-baseline justify-between gap-3 flex-wrap">
<dt class="opacity-70 shrink-0">Elevation</dt>
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
<span class={"badge badge-sm whitespace-nowrap " <> elevation_badge_class(@moon && @moon.elevation)}>
<%= cond do %>
<% is_nil(@moon) -> %>
<% @moon.elevation >= 5.0 -> %>
above horizon
<% @moon.elevation >= 0.0 -> %>
low
<% true -> %>
below horizon
<% end %>
</span>
<span>{format_deg(@moon && @moon.elevation)}</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Slant range</dt>
<dd class="font-mono text-right">
{format_distance_km(@moon && @moon.distance_km)}
</dd>
</div>
</dl>
</div>
<div class="bg-base-100 border border-base-300 rounded-box p-4">
<h2 class="text-base font-semibold">Link budget (Earth → Moon → Earth)</h2>
<dl class="mt-3 text-sm space-y-1.5">
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">TX power</dt>
<dd class="font-mono text-right">
{format_dbm(@link && @link.tx_power_dbm)}
<span class="opacity-60 text-xs">
({format_watts(@link && @link.tx_power_w)})
</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Antenna gain</dt>
<dd class="font-mono text-right">
+{@link && :erlang.float_to_binary(@link.tx_gain_dbi * 1.0, decimals: 1)} dBi
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 border-t border-base-300 pt-1.5">
<dt class="opacity-70 shrink-0">= EIRP</dt>
<dd class="font-mono text-right font-semibold">
{format_dbm(@link && @link.eirp_dbm)}
<span class="opacity-60 text-xs">({format_watts(@link && @link.eirp_w)})</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 mt-2">
<dt class="opacity-70 shrink-0">Free-space spreading (×2)</dt>
<dd class="font-mono text-right">
{format_db(@link && @link.fspl_round_trip_db)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">
Moon reflection
<span class="text-xs opacity-70">
(ρ = {format_albedo(@link && @link.moon_albedo)})
</span>
</dt>
<dd class="font-mono text-right text-success">
{format_db(@link && @link.moon_reflection_gain_db)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 border-t border-base-300 pt-1.5">
<dt class="opacity-70 shrink-0">= Round-trip path loss</dt>
<dd class="font-mono text-right font-semibold">
{format_db(@link && @link.path_loss_db)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 mt-2">
<dt class="opacity-70 shrink-0">Received power</dt>
<dd class="font-mono text-right">
{format_dbm(@link && @link.received_power_dbm)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Noise floor</dt>
<dd class="font-mono text-right">{format_dbm(@link && @link.noise_floor_dbm)}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Doppler (self-echo)</dt>
<dd class="font-mono text-right">
{format_doppler(@link && @link.doppler_hz)}
<span class="opacity-60 text-xs">
({format_radial_velocity(@link && @link.radial_velocity_km_s)})
</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 flex-wrap border-t border-base-300 pt-1.5">
<dt class="opacity-70 shrink-0">SNR</dt>
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
<span class={"badge badge-sm whitespace-nowrap " <> snr_badge_class(@link && @link.snr_db)}>
<%= cond do %>
<% is_nil(@link) -> %>
<% @link.snr_db >= 10 -> %>
strong
<% @link.snr_db >= 0 -> %>
marginal
<% true -> %>
below noise
<% end %>
</span>
<span class="font-semibold">{format_db(@link && @link.snr_db)}</span>
</dd>
</div>
</dl>
<p class="text-xs opacity-60 mt-3">
Round-trip path loss = 2 × free-space spreading minus the Moon's
effective scattering gain <code>10·log(4π σ / λ²)</code>, with σ = ρ·π·R²<sub>moon</sub>.
Albedo ρ varies by band (≈ 0.065 at VHF, ≈ 0.08 at microwave, dropping above 40 GHz).
Cable, feed, and polarisation losses are on you.
</p>
</div>
</section>
<%= if @moon do %>
<section class="bg-base-100 border border-base-300 rounded-box p-4">
<div class="flex items-baseline justify-between gap-3 flex-wrap">
<h2 class="text-base font-semibold">EarthMoon geometry (not to scale)</h2>
<span class="text-xs opacity-60">
Drag to rotate. Shaded hemisphere is everyone who cannot currently see the Moon — outside your bounce footprint.
</span>
</div>
<div
id="eme-globe"
phx-hook="EmeGlobe"
phx-update="ignore"
data-station-lat={Float.round(@source_resolved.lat, 4)}
data-station-lon={Float.round(@source_resolved.lon, 4)}
data-moon-az={Float.round(@moon.azimuth, 3)}
data-moon-el={Float.round(@moon.elevation, 3)}
data-moon-visible={to_string(@moon.elevation >= 0.0)}
class="mt-3 rounded-box overflow-hidden bg-[#050914]"
style="min-height: 360px;"
>
</div>
<%= if @moon.elevation < 0.0 do %>
<p class="text-xs text-warning mt-2">
Moon is below your horizon right now — no line-of-sight path. Wait for moonrise.
</p>
<% end %>
<p class="text-[10px] opacity-50 mt-2">
Earth texture: NASA Blue Marble 2015 (public domain). Moon texture: NASA CGI Moon Kit, LROC colour mosaic (public domain).
</p>
</section>
<% end %>
<% else %>
<div class="text-sm opacity-70">
Enter your callsign or grid above to start the live Moon aim readout.
</div>
<% end %>
</div>
</Layouts.app>
"""
end
end