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

EME Calculator

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.

<%= if @error do %>
{@error}
<% end %> <%= if @source_resolved do %>

Antenna aim

{Calendar.strftime(@now_utc, "%Y-%m-%d %H:%M:%SZ")}
Station
{@source_resolved.label}
Lat / lon
{Float.round(@source_resolved.lat, 3)}, {Float.round(@source_resolved.lon, 3)}
Azimuth
{format_deg(@moon && @moon.azimuth)}
Elevation
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 %> {format_deg(@moon && @moon.elevation)}
Slant range
{format_distance_km(@moon && @moon.distance_km)}

Link budget (Earth → Moon → Earth)

TX power
{format_dbm(@link && @link.tx_power_dbm)} ({format_watts(@link && @link.tx_power_w)})
Antenna gain
+{@link && :erlang.float_to_binary(@link.tx_gain_dbi * 1.0, decimals: 1)} dBi
= EIRP
{format_dbm(@link && @link.eirp_dbm)} ({format_watts(@link && @link.eirp_w)})
Free-space spreading (×2)
{format_db(@link && @link.fspl_round_trip_db)}
Moon reflection (ρ = {format_albedo(@link && @link.moon_albedo)})
−{format_db(@link && @link.moon_reflection_gain_db)}
= Round-trip path loss
{format_db(@link && @link.path_loss_db)}
Received power
{format_dbm(@link && @link.received_power_dbm)}
Noise floor
{format_dbm(@link && @link.noise_floor_dbm)}
Doppler (self-echo)
{format_doppler(@link && @link.doppler_hz)} ({format_radial_velocity(@link && @link.radial_velocity_km_s)})
SNR
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 %> {format_db(@link && @link.snr_db)}

Round-trip path loss = 2 × free-space spreading minus the Moon's effective scattering gain 10·log(4π σ / λ²), with σ = ρ·π·R²moon. Albedo ρ varies by band (≈ 0.065 at VHF, ≈ 0.08 at microwave, dropping above 40 GHz). Cable, feed, and polarisation losses are on you.

<%= if @moon do %>

Earth–Moon geometry (not to scale)

Drag to rotate. Shaded hemisphere is everyone who cannot currently see the Moon — outside your bounce footprint.
= 0.0)} class="mt-3 rounded-box overflow-hidden bg-[#050914]" style="min-height: 360px;" >
<%= if @moon.elevation < 0.0 do %>

Moon is below your horizon right now — no line-of-sight path. Wait for moonrise.

<% end %>

Earth texture: NASA Blue Marble 2015 (public domain). Moon texture: NASA CGI Moon Kit, LROC colour mosaic (public domain).

<% end %> <% else %>
Enter your callsign or grid above to start the live Moon aim readout.
<% end %>
""" end end