prop/lib/microwaveprop_web/live/eme_live.ex
Graham McIntire 2defa3db7a
refactor: deduplicate helpers and simplify Scorer
- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
  form for antipodal stability); Radio, common_volume, rain_scatter,
  ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
  path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
  resolve_source / resolve_location duplicate. Standardize the kind
  key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
  rain, pwat, pressure) to multi-clause guarded defs, matching the
  existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
  6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
  both use it.
2026-04-28 14:14:34 -05:00

566 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"
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