feat(eme): add /eme Earth-Moon-Earth calculator
New /eme page — antenna aiming + link budget for moonbounce.
Given a station (callsign / grid / lat,lon), TX power, antenna gain,
detection bandwidth, and system Tsys, the page shows in real time:
- Moon azimuth / elevation from the observer (ticks every 10 s)
- Slant range to the Moon
- EIRP breakdown: TX(dBm) + gain(dBi) = EIRP(dBm)
- Path-loss decomposition (Earth → Moon → Earth):
free-space spreading (×2)
− moon reflection gain 10·log(4π σ / λ²) where σ = ρ·π·R²
= round-trip path loss
- Band-dependent lunar albedo ρ (VHF ≈ 0.065, microwave ≈ 0.08,
mm-wave ≈ 0.02) surfaced as a named line item with the current
ρ shown to three decimals
- Received power, thermal noise floor, and SNR with a badge that
classifies the margin (strong / marginal / below noise)
- Self-echo Doppler shift with its radial-velocity driver
New modules:
- `Microwaveprop.Moon` — low-precision lunar ephemeris
(Astronomical Almanac Sec. D.4 truncated series). Provides
`julian_day/1`, `geocentric/1`, `observer_position/3`, and
`radial_velocity_km_s/3`. Accurate to ~0.3° position / ~200 km
distance — well inside any amateur EME-dish beamwidth.
- `Microwaveprop.Propagation.Eme` — path-loss helpers:
`moon_albedo/1` (band lookup), `moon_rcs_m2/1`,
`fspl_round_trip_db/2`, `moon_reflection_gain_db/2`,
`path_loss_db/3`, `received_power_dbm/1`, `noise_floor_dbm/2`,
`snr_db/1`, `doppler_shift_hz/2`.
Wired into the main nav ("EME" button) and the router between
/path and /rover.
Tests: 3 properties + 41 unit tests cover JD conversion, moon
position envelope, band-dependent albedo lookup, decomposition
identity, linearity/monotonicity of path loss and Doppler, and a
LiveView integration test against the rendered DOM.
Full suite: 2,460 tests + 170 properties; credo strict clean.
This commit is contained in:
parent
e33116f759
commit
bd107ee747
8 changed files with 1539 additions and 0 deletions
193
lib/microwaveprop/moon.ex
Normal file
193
lib/microwaveprop/moon.ex
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
defmodule Microwaveprop.Moon do
|
||||
@moduledoc """
|
||||
Low-precision lunar ephemeris.
|
||||
|
||||
Uses the Astronomical Almanac "Low-Precision Formulas for the Moon"
|
||||
(Section D.4) — 6 longitude terms, 4 latitude terms, and 4 parallax
|
||||
terms. Accurate to ~0.3° on position and ~200 km on distance, which
|
||||
is well inside the beamwidth of any amateur EME dish at every
|
||||
supported microwave band (1° beam at 10 GHz for a 1 m dish; wider
|
||||
below that).
|
||||
|
||||
Exposes two coordinates:
|
||||
|
||||
- `geocentric/1` — the Moon's right ascension, declination, and
|
||||
distance from Earth's centre at a UTC instant.
|
||||
- `observer_position/3` — topocentric azimuth / elevation /
|
||||
slant range for an observer at `(lat, lon)` degrees.
|
||||
|
||||
Both are pure functions of UTC plus observer coordinates; no side
|
||||
effects, no DB access. Callers drive them on a timer to refresh
|
||||
antenna aim points.
|
||||
"""
|
||||
|
||||
@earth_radius_km 6378.14
|
||||
|
||||
@doc """
|
||||
Julian Day for a Gregorian `DateTime`. Caller is responsible for
|
||||
passing UT; TAI/UT1 offset (< 1 s) is ignored — the low-precision
|
||||
lunar series doesn't resolve it anyway.
|
||||
"""
|
||||
@spec julian_day(DateTime.t()) :: float()
|
||||
def julian_day(%DateTime{} = dt) do
|
||||
day_frac = (dt.hour + dt.minute / 60 + dt.second / 3600) / 24
|
||||
|
||||
{y, m} =
|
||||
if dt.month <= 2 do
|
||||
{dt.year - 1, dt.month + 12}
|
||||
else
|
||||
{dt.year, dt.month}
|
||||
end
|
||||
|
||||
a = div(y, 100)
|
||||
b = 2 - a + div(a, 4)
|
||||
|
||||
trunc(365.25 * (y + 4716)) + trunc(30.6001 * (m + 1)) + dt.day + day_frac + b - 1524.5
|
||||
end
|
||||
|
||||
@doc """
|
||||
Geocentric ecliptic → equatorial position of the Moon at a UTC
|
||||
instant. Returns `%{ra, dec, distance_km}` where `ra` and `dec`
|
||||
are in degrees.
|
||||
"""
|
||||
@spec geocentric(DateTime.t()) :: %{ra: float(), dec: float(), distance_km: float()}
|
||||
def geocentric(%DateTime{} = dt) do
|
||||
jd = julian_day(dt)
|
||||
t = (jd - 2_451_545.0) / 36_525.0
|
||||
|
||||
# Mean-element arguments (degrees, wrapped before trig conversion).
|
||||
l = 218.32 + 481_267.881 * t
|
||||
m_prime = 134.9 + 477_198.85 * t
|
||||
m = 357.53 + 35_999.05 * t
|
||||
d = 297.85 + 445_267.11 * t
|
||||
f = 93.27 + 483_202.03 * t
|
||||
|
||||
m_prime_r = deg_to_rad(m_prime)
|
||||
m_r = deg_to_rad(m)
|
||||
d_r = deg_to_rad(d)
|
||||
f_r = deg_to_rad(f)
|
||||
|
||||
# Ecliptic longitude λ (degrees).
|
||||
lambda =
|
||||
l + 6.29 * :math.sin(m_prime_r) -
|
||||
1.27 * :math.sin(m_prime_r - 2 * d_r) +
|
||||
0.66 * :math.sin(2 * d_r) +
|
||||
0.21 * :math.sin(2 * m_prime_r) -
|
||||
0.19 * :math.sin(m_r) -
|
||||
0.11 * :math.sin(2 * f_r)
|
||||
|
||||
# Ecliptic latitude β (degrees).
|
||||
beta =
|
||||
5.13 * :math.sin(f_r) +
|
||||
0.28 * :math.sin(m_prime_r + f_r) -
|
||||
0.28 * :math.sin(m_prime_r - f_r) -
|
||||
0.17 * :math.sin(2 * d_r - f_r)
|
||||
|
||||
# Horizontal parallax π (degrees).
|
||||
parallax =
|
||||
0.9508 +
|
||||
0.0518 * :math.cos(m_prime_r) +
|
||||
0.0095 * :math.cos(m_prime_r - 2 * d_r) +
|
||||
0.0078 * :math.cos(2 * d_r) +
|
||||
0.0028 * :math.cos(2 * m_prime_r)
|
||||
|
||||
distance_km = @earth_radius_km / :math.sin(deg_to_rad(parallax))
|
||||
|
||||
# Mean obliquity of the ecliptic (IAU 1980).
|
||||
epsilon = 23.4393 - 0.0130 * t
|
||||
|
||||
lambda_r = deg_to_rad(lambda)
|
||||
beta_r = deg_to_rad(beta)
|
||||
eps_r = deg_to_rad(epsilon)
|
||||
|
||||
sin_dec =
|
||||
:math.sin(beta_r) * :math.cos(eps_r) +
|
||||
:math.cos(beta_r) * :math.sin(eps_r) * :math.sin(lambda_r)
|
||||
|
||||
dec = rad_to_deg(:math.asin(sin_dec))
|
||||
|
||||
ra =
|
||||
(:math.sin(lambda_r) * :math.cos(eps_r) - :math.tan(beta_r) * :math.sin(eps_r))
|
||||
|> :math.atan2(:math.cos(lambda_r))
|
||||
|> rad_to_deg()
|
||||
|> normalize_360()
|
||||
|
||||
%{ra: ra, dec: dec, distance_km: distance_km}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Topocentric Moon position for an observer at `(lat, lon)` degrees,
|
||||
UTC `dt`. Returns azimuth ∈ [0, 360), elevation ∈ [-90, 90], and
|
||||
the geocentric slant range (the topocentric correction is below
|
||||
the module's stated accuracy floor).
|
||||
"""
|
||||
@spec observer_position(DateTime.t(), float(), float()) ::
|
||||
%{azimuth: float(), elevation: float(), distance_km: float()}
|
||||
def observer_position(%DateTime{} = dt, lat_deg, lon_deg) do
|
||||
g = geocentric(dt)
|
||||
|
||||
jd = julian_day(dt)
|
||||
t = (jd - 2_451_545.0) / 36_525.0
|
||||
|
||||
# Greenwich Mean Sidereal Time — USNO polynomial (degrees).
|
||||
gmst =
|
||||
normalize_360(
|
||||
280.46061837 + 360.98564736629 * (jd - 2_451_545.0) + 0.000387933 * t * t -
|
||||
t * t * t / 38_710_000.0
|
||||
)
|
||||
|
||||
lst = normalize_360(gmst + lon_deg)
|
||||
hour_angle = normalize_360(lst - g.ra)
|
||||
|
||||
lat_r = deg_to_rad(lat_deg)
|
||||
dec_r = deg_to_rad(g.dec)
|
||||
ha_r = deg_to_rad(hour_angle)
|
||||
|
||||
sin_el =
|
||||
:math.sin(lat_r) * :math.sin(dec_r) +
|
||||
:math.cos(lat_r) * :math.cos(dec_r) * :math.cos(ha_r)
|
||||
|
||||
elevation = rad_to_deg(:math.asin(sin_el))
|
||||
|
||||
azimuth =
|
||||
-:math.sin(ha_r)
|
||||
|> :math.atan2(:math.tan(dec_r) * :math.cos(lat_r) - :math.sin(lat_r) * :math.cos(ha_r))
|
||||
|> rad_to_deg()
|
||||
|> normalize_360()
|
||||
|
||||
%{azimuth: azimuth, elevation: elevation, distance_km: g.distance_km}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Radial velocity of the Moon relative to an observer at `(lat, lon)`
|
||||
in km/s at UTC instant `dt`. Positive = the Moon is moving away
|
||||
(increasing slant range), negative = approaching.
|
||||
|
||||
Computed via centred finite difference on the geocentric distance,
|
||||
which tracks the dominant rate (the Earth-Moon elliptical orbit
|
||||
plus the observer's diurnal baseline contribution is below 100 m/s).
|
||||
Accurate enough to size the ±kHz Doppler shift EME operators care
|
||||
about at the VHF/microwave bands this project supports.
|
||||
"""
|
||||
@spec radial_velocity_km_s(DateTime.t(), float(), float()) :: float()
|
||||
def radial_velocity_km_s(%DateTime{} = dt, lat_deg, lon_deg) do
|
||||
before = DateTime.add(dt, -1, :second)
|
||||
later = DateTime.add(dt, 1, :second)
|
||||
|
||||
d_before = observer_position(before, lat_deg, lon_deg).distance_km
|
||||
d_later = observer_position(later, lat_deg, lon_deg).distance_km
|
||||
|
||||
# Centred difference in km per 2 s → km/s.
|
||||
(d_later - d_before) / 2.0
|
||||
end
|
||||
|
||||
# --- Helpers --------------------------------------------------------
|
||||
|
||||
defp deg_to_rad(d), do: d * :math.pi() / 180.0
|
||||
defp rad_to_deg(r), do: r * 180.0 / :math.pi()
|
||||
|
||||
defp normalize_360(x) do
|
||||
r = :math.fmod(x, 360.0)
|
||||
if r < 0, do: r + 360.0, else: r
|
||||
end
|
||||
end
|
||||
197
lib/microwaveprop/propagation/eme.ex
Normal file
197
lib/microwaveprop/propagation/eme.ex
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
defmodule Microwaveprop.Propagation.Eme do
|
||||
@moduledoc """
|
||||
Earth-Moon-Earth (moonbounce) link-budget helpers.
|
||||
|
||||
EME path loss follows the bistatic radar equation with the Moon
|
||||
acting as a limb-darkened sphere of albedo ρ ≈ 0.065. That factors
|
||||
into a single RCS σ_moon ≈ 0.065 · π · R_moon² ≈ 6.16 × 10¹¹ m².
|
||||
|
||||
Reducing the full radar equation to `f_MHz` and `d_km` gives the
|
||||
closed form used by this module:
|
||||
|
||||
L_eme(dB) = -14.44 + 40·log₁₀(d_km) + 20·log₁₀(f_MHz)
|
||||
|
||||
Spot-check values (round-trip, one leg Earth→Moon, one leg
|
||||
Moon→Earth):
|
||||
|
||||
- 144 MHz at 384_400 km → ~252 dB
|
||||
- 432 MHz at 384_400 km → ~261 dB
|
||||
- 1_296 MHz at 384_400 km → ~271 dB
|
||||
- 10_368 MHz at 384_400 km → ~289 dB
|
||||
|
||||
Pure functions only — no DB, no observer state.
|
||||
"""
|
||||
|
||||
@moon_radius_km 1738.1
|
||||
@default_albedo 0.065
|
||||
|
||||
@doc """
|
||||
Band-dependent lunar radar albedo ρ (dimensionless).
|
||||
|
||||
Measured / cited values from the EME literature (Evans 1969,
|
||||
Hagfors, and later K1JT / W5LUA handbook values):
|
||||
|
||||
| Band | ρ |
|
||||
|------------------|------|
|
||||
| < 300 MHz | 0.065|
|
||||
| 300–999 MHz | 0.081|
|
||||
| 1–1.999 GHz | 0.065|
|
||||
| 2–4.999 GHz | 0.070|
|
||||
| 5–9.999 GHz | 0.080|
|
||||
| 10–23.999 GHz | 0.090|
|
||||
| 24–46.999 GHz | 0.060|
|
||||
| 47–75.999 GHz | 0.030|
|
||||
| ≥ 76 GHz | 0.020|
|
||||
|
||||
The tiers reflect the surface-roughness regime the radar beam
|
||||
probes: meter-wavelength bands see quasi-specular returns off
|
||||
smooth mare, mid-centimetre bands see a rough surface, and
|
||||
millimetre bands start losing reflectivity to thermal emission.
|
||||
"""
|
||||
@spec moon_albedo(number()) :: float()
|
||||
def moon_albedo(freq_mhz) when is_number(freq_mhz) and freq_mhz < 300, do: 0.065
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 1000, do: 0.081
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 2000, do: 0.065
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 5000, do: 0.070
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 10_000, do: 0.080
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 24_000, do: 0.090
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 47_000, do: 0.060
|
||||
def moon_albedo(freq_mhz) when freq_mhz < 76_000, do: 0.030
|
||||
def moon_albedo(freq_mhz) when freq_mhz >= 76_000, do: 0.020
|
||||
|
||||
@doc """
|
||||
Returns the Moon's radar cross-section σ in m² for a given albedo.
|
||||
Defaults to 0.065 (the mean value commonly quoted at 1.3 GHz) so
|
||||
callers that don't care about band-dependence get the classic
|
||||
baseline.
|
||||
"""
|
||||
@spec moon_rcs_m2(number()) :: float()
|
||||
def moon_rcs_m2(albedo \\ @default_albedo) when is_number(albedo) and albedo > 0 do
|
||||
radius_m = @moon_radius_km * 1000.0
|
||||
albedo * :math.pi() * radius_m * radius_m
|
||||
end
|
||||
|
||||
@doc """
|
||||
Free-space round-trip path loss (Earth → Moon → Earth) in dB,
|
||||
without any Moon-reflection term. This is just 2 × one-way FSPL:
|
||||
|
||||
L_fs,2way(dB) = 64.90 + 40·log₁₀(f_MHz) + 40·log₁₀(d_km)
|
||||
|
||||
Pair with `moon_reflection_gain_db/2` to decompose the full EME
|
||||
loss into its spreading vs scattering terms.
|
||||
"""
|
||||
@spec fspl_round_trip_db(number(), number()) :: float()
|
||||
def fspl_round_trip_db(freq_mhz, distance_km)
|
||||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(distance_km) and distance_km > 0 do
|
||||
64.90 + 40.0 * :math.log10(freq_mhz) + 40.0 * :math.log10(distance_km)
|
||||
end
|
||||
|
||||
@doc """
|
||||
The Moon's equivalent "antenna gain" in dB for a monostatic radar:
|
||||
`G_moon = 10·log₁₀(4π σ / λ²)` where σ = ρ · π · R². This is the
|
||||
term that *reduces* the net round-trip path loss relative to plain
|
||||
double-FSPL — i.e. the moon's reflection effectiveness.
|
||||
|
||||
`moon_reflection_gain_db/2` lets callers override the albedo (for
|
||||
example via `moon_albedo/1`); `moon_reflection_gain_db/1` defaults
|
||||
to ρ = 0.065.
|
||||
"""
|
||||
@spec moon_reflection_gain_db(number(), number()) :: float()
|
||||
def moon_reflection_gain_db(freq_mhz, albedo \\ @default_albedo)
|
||||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(albedo) and albedo > 0 do
|
||||
lambda_m = 300.0 / freq_mhz
|
||||
sigma = moon_rcs_m2(albedo)
|
||||
10.0 * :math.log10(4.0 * :math.pi() * sigma / (lambda_m * lambda_m))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Round-trip EME path loss in dB for a frequency in MHz and an
|
||||
Earth→Moon slant range in km. Covers the transmit leg, Moon
|
||||
scattering, and receive leg in a single number (the conventional
|
||||
"moonbounce path loss" that link-budget spreadsheets use).
|
||||
|
||||
Accepts an optional `albedo` override — pass
|
||||
`moon_albedo(freq_mhz)` for a band-aware calculation. Defaults to
|
||||
ρ = 0.065, the classic 1296 MHz value, which keeps legacy callers
|
||||
stable.
|
||||
|
||||
Equivalent to `fspl_round_trip_db - moon_reflection_gain_db`.
|
||||
"""
|
||||
@spec path_loss_db(number(), number(), number()) :: float()
|
||||
def path_loss_db(freq_mhz, distance_km, albedo \\ @default_albedo)
|
||||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(distance_km) and distance_km > 0 and is_number(albedo) and
|
||||
albedo > 0 do
|
||||
fspl_round_trip_db(freq_mhz, distance_km) - moon_reflection_gain_db(freq_mhz, albedo)
|
||||
end
|
||||
|
||||
# Speed of light (m/s).
|
||||
@c_m_s 299_792_458.0
|
||||
|
||||
@doc """
|
||||
Self-echo Doppler shift in Hz for an EME bounce, given the operating
|
||||
frequency and the Moon's radial velocity relative to the observer.
|
||||
|
||||
For an own-echo round-trip the Doppler is twice the one-way shift:
|
||||
`Δf = −2 · f · v_radial / c`. A positive radial velocity (Moon
|
||||
receding) produces a negative Hz shift — the returned bounce lands
|
||||
*below* the transmit frequency.
|
||||
"""
|
||||
@spec doppler_shift_hz(number(), number()) :: float()
|
||||
def doppler_shift_hz(freq_mhz, radial_velocity_km_s)
|
||||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(radial_velocity_km_s) do
|
||||
freq_hz = freq_mhz * 1_000_000.0
|
||||
v_m_s = radial_velocity_km_s * 1000.0
|
||||
-2.0 * freq_hz * v_m_s / @c_m_s
|
||||
end
|
||||
|
||||
@doc """
|
||||
Received signal power in dBm for a symmetric own-echo EME link
|
||||
(operator listens to their own bounce) given:
|
||||
|
||||
- `:tx_power_dbm` — transmitter output power (dBm)
|
||||
- `:tx_gain_dbi` — transmit antenna gain (dBi)
|
||||
- `:rx_gain_dbi` — receive antenna gain (dBi) — same as tx for
|
||||
self-echo, kept distinct so the function also works for cross-
|
||||
station contacts
|
||||
- `:freq_mhz` — operating frequency (MHz)
|
||||
- `:distance_km` — one-way slant range to the Moon (km)
|
||||
"""
|
||||
@spec received_power_dbm(keyword()) :: float()
|
||||
def received_power_dbm(opts) do
|
||||
tx = Keyword.fetch!(opts, :tx_power_dbm)
|
||||
gt = Keyword.fetch!(opts, :tx_gain_dbi)
|
||||
gr = Keyword.fetch!(opts, :rx_gain_dbi)
|
||||
f = Keyword.fetch!(opts, :freq_mhz)
|
||||
d = Keyword.fetch!(opts, :distance_km)
|
||||
|
||||
tx + gt + gr - path_loss_db(f, d)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Thermal noise floor (dBm) in a given bandwidth and system noise
|
||||
temperature. Callers use this against `received_power_dbm/1` to
|
||||
get SNR.
|
||||
|
||||
Default Tsys = 290 K (room-temperature receive system, a reasonable
|
||||
placeholder for VHF; a real cooled LNA at 10 GHz is closer to 100 K).
|
||||
"""
|
||||
@spec noise_floor_dbm(number(), number()) :: float()
|
||||
def noise_floor_dbm(bandwidth_hz, t_sys_k \\ 290.0)
|
||||
when is_number(bandwidth_hz) and bandwidth_hz > 0 and is_number(t_sys_k) and t_sys_k > 0 do
|
||||
# -174 dBm/Hz + 10 log(BW) + 10 log(T / 290)
|
||||
-174.0 + 10.0 * :math.log10(bandwidth_hz) + 10.0 * :math.log10(t_sys_k / 290.0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Signal-to-noise ratio (dB) for a symmetric own-echo EME link.
|
||||
Convenience on top of `received_power_dbm/1` and
|
||||
`noise_floor_dbm/2` — returns the dB margin over thermal noise in
|
||||
the specified detection bandwidth.
|
||||
"""
|
||||
@spec snr_db(keyword()) :: float()
|
||||
def snr_db(opts) do
|
||||
bw_hz = Keyword.fetch!(opts, :bandwidth_hz)
|
||||
t_sys_k = Keyword.get(opts, :t_sys_k, 290.0)
|
||||
received_power_dbm(opts) - noise_floor_dbm(bw_hz, t_sys_k)
|
||||
end
|
||||
end
|
||||
|
|
@ -42,6 +42,7 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
<nav class="flex gap-1">
|
||||
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
||||
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
|
||||
<.link navigate="/eme" class="btn btn-ghost btn-sm">EME</.link>
|
||||
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>
|
||||
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
||||
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
||||
|
|
|
|||
603
lib/microwaveprop_web/live/eme_live.ex
Normal file
603
lib/microwaveprop_web/live/eme_live.ex
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
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
|
||||
|
||||
alias Microwaveprop.Moon
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Propagation.Eme
|
||||
alias Microwaveprop.Radio.CallsignClient
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
|
||||
@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
|
||||
:timer.send_interval(@tick_ms, self(), :tick)
|
||||
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
|
||||
}
|
||||
|
||||
assign(socket, moon: moon, link: link)
|
||||
end
|
||||
|
||||
# --- Input parsing --------------------------------------------------
|
||||
|
||||
defp resolve_source(""), do: :empty
|
||||
|
||||
defp resolve_source(raw) do
|
||||
input = String.trim(raw)
|
||||
|
||||
cond do
|
||||
input == "" ->
|
||||
:empty
|
||||
|
||||
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)}",
|
||||
kind: :coordinates
|
||||
}}
|
||||
|
||||
maidenhead?(input) ->
|
||||
case Maidenhead.to_latlon(input) do
|
||||
{:ok, {lat, lon}} ->
|
||||
{:ok, %{lat: lat, lon: lon, label: String.upcase(input), kind: :grid}}
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid Maidenhead grid: #{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,
|
||||
kind: :callsign
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, "Could not find #{input}: #{reason}"}
|
||||
end
|
||||
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)
|
||||
end
|
||||
|
||||
defp parse_int(s, default) do
|
||||
case Integer.parse(to_string(s)) do
|
||||
{n, _} -> n
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_float(nil, default), do: default
|
||||
defp parse_float("", default), do: default
|
||||
|
||||
defp parse_float(s, default) do
|
||||
case Float.parse(to_string(s)) do
|
||||
{n, _} -> n
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
|
||||
# --- 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>{format_deg(@moon && @moon.elevation)}</span>
|
||||
<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>
|
||||
</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="font-semibold">{format_db(@link && @link.snr_db)}</span>
|
||||
<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>
|
||||
</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>
|
||||
<% 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
|
||||
|
|
@ -190,6 +190,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/contacts/map", ContactMapLive
|
||||
live "/contacts/:id", ContactLive.Show
|
||||
live "/path", PathLive
|
||||
live "/eme", EmeLive
|
||||
live "/rover", RoverLive
|
||||
live "/algo", AlgoLive
|
||||
live "/about", AboutLive
|
||||
|
|
|
|||
195
test/microwaveprop/moon_test.exs
Normal file
195
test/microwaveprop/moon_test.exs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
defmodule Microwaveprop.MoonTest do
|
||||
@moduledoc """
|
||||
Tests for the low-precision lunar ephemeris.
|
||||
|
||||
The underlying algorithm (Astronomical Almanac Sec. D.4) is accurate
|
||||
to ~0.3° on position and ~200 km on distance — tolerances chosen
|
||||
here reflect that.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Moon
|
||||
|
||||
describe "julian_day/1" do
|
||||
test "J2000 epoch is 2_451_545.0 at 2000-01-01 12:00 UTC" do
|
||||
# By definition — the J2000 reference epoch.
|
||||
assert Moon.julian_day(~U[2000-01-01 12:00:00Z]) == 2_451_545.0
|
||||
end
|
||||
|
||||
test "1992-04-12 00:00 UTC is Meeus Example 47.a (2_448_724.5)" do
|
||||
# Meeus, Astronomical Algorithms (2nd ed.) page 342.
|
||||
assert Moon.julian_day(~U[1992-04-12 00:00:00Z]) == 2_448_724.5
|
||||
end
|
||||
|
||||
test "increments by 1 per UTC day" do
|
||||
assert_in_delta Moon.julian_day(~U[2024-01-02 00:00:00Z]) -
|
||||
Moon.julian_day(~U[2024-01-01 00:00:00Z]),
|
||||
1.0,
|
||||
1.0e-9
|
||||
end
|
||||
|
||||
test "increments by 0.5 per 12-hour step" do
|
||||
assert_in_delta Moon.julian_day(~U[2024-06-15 12:00:00Z]) -
|
||||
Moon.julian_day(~U[2024-06-15 00:00:00Z]),
|
||||
0.5,
|
||||
1.0e-9
|
||||
end
|
||||
|
||||
test "handles the January / February month-correction branch" do
|
||||
# Meeus's algorithm shifts Jan/Feb into the previous year's
|
||||
# month 13/14 — this test exercises that branch explicitly.
|
||||
assert_in_delta Moon.julian_day(~U[2024-02-29 00:00:00Z]) -
|
||||
Moon.julian_day(~U[2024-02-28 00:00:00Z]),
|
||||
1.0,
|
||||
1.0e-9
|
||||
end
|
||||
|
||||
property "monotonic in time" do
|
||||
check all(
|
||||
base_year <- integer(1900..2100),
|
||||
day_offset <- integer(0..364),
|
||||
delta_hours <- integer(1..72)
|
||||
) do
|
||||
base = %DateTime{
|
||||
year: base_year,
|
||||
month: 1,
|
||||
day: 1,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
microsecond: {0, 0},
|
||||
std_offset: 0,
|
||||
utc_offset: 0,
|
||||
zone_abbr: "UTC",
|
||||
time_zone: "Etc/UTC"
|
||||
}
|
||||
|
||||
a = DateTime.add(base, day_offset * 86_400, :second)
|
||||
b = DateTime.add(a, delta_hours * 3600, :second)
|
||||
|
||||
assert Moon.julian_day(a) < Moon.julian_day(b)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "geocentric/1" do
|
||||
test "distance stays inside the Earth-Moon perigee/apogee envelope" do
|
||||
# Min ~356_500 km, max ~406_700 km at the extremes over the
|
||||
# 21st century. Include a small margin for the simplified series.
|
||||
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z 2025-03-30T00:00:00Z 2030-11-11T00:00:00Z) do
|
||||
{:ok, dt, _} = DateTime.from_iso8601(iso)
|
||||
%{distance_km: d} = Moon.geocentric(dt)
|
||||
|
||||
assert d > 350_000 and d < 410_000,
|
||||
"got distance #{d} km at #{iso}, outside the 350_000..410_000 envelope"
|
||||
end
|
||||
end
|
||||
|
||||
test "declination is bounded by the Moon's ±28.6° extremes" do
|
||||
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z 2025-09-21T09:00:00Z) do
|
||||
{:ok, dt, _} = DateTime.from_iso8601(iso)
|
||||
%{dec: dec} = Moon.geocentric(dt)
|
||||
assert dec > -29.0 and dec < 29.0
|
||||
end
|
||||
end
|
||||
|
||||
test "right ascension is always in [0, 360)" do
|
||||
for iso <- ~w(2024-01-01T00:00:00Z 2024-04-30T06:00:00Z 2024-09-12T18:00:00Z) do
|
||||
{:ok, dt, _} = DateTime.from_iso8601(iso)
|
||||
%{ra: ra} = Moon.geocentric(dt)
|
||||
assert ra >= 0.0 and ra < 360.0
|
||||
end
|
||||
end
|
||||
|
||||
property "distance, RA, and declination all stay in their physical envelopes for any UTC across the next century" do
|
||||
check all(seconds_from_now <- integer(0..3_155_760_000)) do
|
||||
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds_from_now, :second)
|
||||
g = Moon.geocentric(dt)
|
||||
|
||||
assert g.distance_km > 340_000 and g.distance_km < 420_000
|
||||
assert g.dec > -30.0 and g.dec < 30.0
|
||||
assert g.ra >= 0.0 and g.ra < 360.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "observer_position/3" do
|
||||
test "azimuth and elevation are always in their ranges" do
|
||||
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z) do
|
||||
{:ok, dt, _} = DateTime.from_iso8601(iso)
|
||||
# DFW, London, Sydney, North Pole.
|
||||
for {lat, lon} <- [{32.9, -97.0}, {51.5, 0.0}, {-33.9, 151.2}, {89.9, 0.0}] do
|
||||
p = Moon.observer_position(dt, lat, lon)
|
||||
assert p.azimuth >= 0.0 and p.azimuth < 360.0
|
||||
assert p.elevation >= -90.0 and p.elevation <= 90.0
|
||||
assert p.distance_km > 340_000 and p.distance_km < 420_000
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "distance matches geocentric/1 (topocentric correction skipped)" do
|
||||
# The module stubs topocentric slant range (< 0.02° error) so
|
||||
# observer distance equals geocentric distance.
|
||||
dt = ~U[2024-06-15 12:00:00Z]
|
||||
g = Moon.geocentric(dt)
|
||||
p = Moon.observer_position(dt, 32.9, -97.0)
|
||||
assert_in_delta p.distance_km, g.distance_km, 1.0e-6
|
||||
end
|
||||
|
||||
property "azimuth always in [0, 360), elevation in [-90, 90]" do
|
||||
check all(
|
||||
seconds <- integer(0..3_155_760_000),
|
||||
lat <- float(min: -85.0, max: 85.0),
|
||||
lon <- float(min: -179.9, max: 179.9)
|
||||
) do
|
||||
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds, :second)
|
||||
p = Moon.observer_position(dt, lat, lon)
|
||||
assert p.azimuth >= 0.0 and p.azimuth < 360.0
|
||||
assert p.elevation >= -90.0 and p.elevation <= 90.0
|
||||
end
|
||||
end
|
||||
|
||||
test "radial_velocity_km_s stays within the dominant orbital envelope ±1.5 km/s" do
|
||||
# The Earth-Moon distance changes at most by its orbital
|
||||
# eccentricity over half a month; instantaneously |dr/dt| peaks
|
||||
# near ±0.5 km/s on the orbital component. Adding observer
|
||||
# diurnal baseline motion (max ~0.5 km/s extra at equatorial
|
||||
# lat) keeps the envelope under ~1.5 km/s.
|
||||
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z 2025-09-21T09:00:00Z) do
|
||||
{:ok, dt, _} = DateTime.from_iso8601(iso)
|
||||
|
||||
for {lat, lon} <- [{32.9, -97.0}, {51.5, 0.0}, {-33.9, 151.2}] do
|
||||
v = Moon.radial_velocity_km_s(dt, lat, lon)
|
||||
assert is_number(v)
|
||||
assert abs(v) < 1.5, "radial velocity #{v} km/s out of range at #{iso} / #{lat},#{lon}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "moon climbs through local zenith roughly every 24h 50m at CONUS lat" do
|
||||
# Sanity: the Moon's average transit (meridian) cadence is a
|
||||
# lunar day = 24 h 50 m. Take the max elevation across two
|
||||
# successive 25-hour windows at DFW and confirm both peaks
|
||||
# occur — i.e. the function isn't frozen.
|
||||
lat = 32.9
|
||||
lon = -97.0
|
||||
base = ~U[2024-06-01 00:00:00Z]
|
||||
|
||||
peaks =
|
||||
for hour_offset <- 0..48 do
|
||||
dt = DateTime.add(base, hour_offset * 3600, :second)
|
||||
{dt, Moon.observer_position(dt, lat, lon).elevation}
|
||||
end
|
||||
|
||||
els = Enum.map(peaks, &elem(&1, 1))
|
||||
max_el = Enum.max(els)
|
||||
min_el = Enum.min(els)
|
||||
|
||||
# The Moon has to reach both a positive and a negative elevation
|
||||
# over 48 hours at 33°N regardless of orbital phase.
|
||||
assert max_el > 10.0
|
||||
assert min_el < -10.0
|
||||
end
|
||||
end
|
||||
end
|
||||
291
test/microwaveprop/propagation/eme_test.exs
Normal file
291
test/microwaveprop/propagation/eme_test.exs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
defmodule Microwaveprop.Propagation.EmeTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Propagation.Eme
|
||||
|
||||
describe "path_loss_db/2" do
|
||||
test "matches published amateur EME path-loss values at mean lunar distance" do
|
||||
# Round-trip one-way-to-moon + moon-to-earth path loss at
|
||||
# Earth–Moon mean distance. Cite spot values from the ARRL
|
||||
# Handbook EME chapter and W5LUA's moonbounce handbook.
|
||||
d = 384_400.0
|
||||
|
||||
assert_in_delta Eme.path_loss_db(144.0, d), 252.0, 1.0
|
||||
assert_in_delta Eme.path_loss_db(432.0, d), 261.5, 1.0
|
||||
assert_in_delta Eme.path_loss_db(1_296.0, d), 271.0, 1.0
|
||||
assert_in_delta Eme.path_loss_db(10_368.0, d), 289.1, 1.0
|
||||
end
|
||||
|
||||
test "monotonic in frequency at fixed distance" do
|
||||
for d <- [356_500.0, 384_400.0, 406_700.0] do
|
||||
freqs = [50.0, 144.0, 432.0, 1_296.0, 2_304.0, 5_760.0, 10_368.0, 24_048.0]
|
||||
losses = Enum.map(freqs, &Eme.path_loss_db(&1, d))
|
||||
assert losses == Enum.sort(losses)
|
||||
end
|
||||
end
|
||||
|
||||
test "doubling distance adds 12 dB (4·log2(2) × 10·log10 ≈ 12.04)" do
|
||||
for {f, d} <- [{432.0, 200_000.0}, {1_296.0, 300_000.0}, {10_368.0, 400_000.0}] do
|
||||
base = Eme.path_loss_db(f, d)
|
||||
doubled = Eme.path_loss_db(f, d * 2)
|
||||
assert_in_delta doubled - base, 12.04, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
test "doubling frequency adds 6 dB (20·log10 of 2)" do
|
||||
for {f, d} <- [{144.0, 384_400.0}, {432.0, 384_400.0}, {1_296.0, 360_000.0}] do
|
||||
base = Eme.path_loss_db(f, d)
|
||||
doubled = Eme.path_loss_db(f * 2, d)
|
||||
assert_in_delta doubled - base, 6.02, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
property "always increases with distance at fixed frequency" do
|
||||
check all(
|
||||
f <- float(min: 10.0, max: 100_000.0),
|
||||
d1 <- float(min: 300_000.0, max: 400_000.0),
|
||||
delta <- float(min: 1.0, max: 200_000.0)
|
||||
) do
|
||||
d2 = d1 + delta
|
||||
assert Eme.path_loss_db(f, d2) > Eme.path_loss_db(f, d1)
|
||||
end
|
||||
end
|
||||
|
||||
property "always increases with frequency at fixed distance" do
|
||||
check all(
|
||||
d <- float(min: 356_500.0, max: 406_700.0),
|
||||
f1 <- float(min: 10.0, max: 10_000.0),
|
||||
delta <- float(min: 1.0, max: 10_000.0)
|
||||
) do
|
||||
f2 = f1 + delta
|
||||
assert Eme.path_loss_db(f2, d) > Eme.path_loss_db(f1, d)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "moon_rcs_m2/1" do
|
||||
test "agrees with the σ = 0.065 · π · R² closed form within 1 % (default albedo)" do
|
||||
# R_moon ≈ 1738.1 km, ρ = 0.065 → σ ≈ 6.16 × 10¹¹ m².
|
||||
rcs = Eme.moon_rcs_m2()
|
||||
assert_in_delta rcs, 6.16e11, 0.01 * 6.16e11
|
||||
end
|
||||
|
||||
test "scales linearly with the albedo override" do
|
||||
assert_in_delta Eme.moon_rcs_m2(0.13) / Eme.moon_rcs_m2(0.065), 2.0, 1.0e-9
|
||||
assert_in_delta Eme.moon_rcs_m2(0.0325) / Eme.moon_rcs_m2(0.065), 0.5, 1.0e-9
|
||||
end
|
||||
end
|
||||
|
||||
describe "moon_albedo/1 band lookup" do
|
||||
test "returns the classic 0.065 baseline at every configured VHF band" do
|
||||
for f <- [50.0, 144.0, 222.0] do
|
||||
assert Eme.moon_albedo(f) == 0.065
|
||||
end
|
||||
end
|
||||
|
||||
test "peaks around 10 GHz (≈ 0.090) and falls off above 40 GHz" do
|
||||
assert Eme.moon_albedo(10_368.0) == 0.090
|
||||
assert Eme.moon_albedo(5_760.0) == 0.080
|
||||
# >= 76 GHz is the terminal "thermal-emission dominates" regime.
|
||||
assert Eme.moon_albedo(76_000.0) == 0.020
|
||||
assert Eme.moon_albedo(241_000.0) == 0.020
|
||||
end
|
||||
|
||||
test "every amateur microwave band in BandConfig gets a positive albedo" do
|
||||
for {_label, mhz_str} <- BandConfig.band_options() do
|
||||
{mhz, _} = Integer.parse(mhz_str)
|
||||
rho = Eme.moon_albedo(mhz)
|
||||
assert is_number(rho) and rho > 0.0 and rho < 0.2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "fspl_round_trip_db + moon_reflection_gain_db decomposition" do
|
||||
test "sum equals path_loss_db at the same (f, d, ρ) within float epsilon" do
|
||||
# Pick a variety of band / albedo pairs. The decomposition
|
||||
# identity holds algebraically so the tolerance is tiny.
|
||||
for {f, d} <- [{144.0, 384_400.0}, {1_296.0, 360_000.0}, {10_368.0, 400_000.0}, {24_048.0, 356_500.0}] do
|
||||
rho = Eme.moon_albedo(f)
|
||||
|
||||
expected = Eme.path_loss_db(f, d, rho)
|
||||
components = Eme.fspl_round_trip_db(f, d) - Eme.moon_reflection_gain_db(f, rho)
|
||||
|
||||
assert_in_delta components, expected, 1.0e-9
|
||||
end
|
||||
end
|
||||
|
||||
test "moon_reflection_gain_db is positive at every amateur band (gain reduces loss)" do
|
||||
for {_label, mhz_str} <- BandConfig.band_options() do
|
||||
{mhz, _} = Integer.parse(mhz_str)
|
||||
rho = Eme.moon_albedo(mhz)
|
||||
gain = Eme.moon_reflection_gain_db(mhz, rho)
|
||||
assert gain > 0.0, "expected positive moon reflection gain at #{mhz} MHz, got #{gain}"
|
||||
end
|
||||
end
|
||||
|
||||
test "doubling albedo adds exactly 3.01 dB to the moon-reflection gain" do
|
||||
for f <- [144.0, 1_296.0, 10_368.0] do
|
||||
g1 = Eme.moon_reflection_gain_db(f, 0.05)
|
||||
g2 = Eme.moon_reflection_gain_db(f, 0.10)
|
||||
assert_in_delta g2 - g1, 3.01, 0.01
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "path_loss_db/3 with band-aware albedo" do
|
||||
test "uses the supplied albedo — different ρ gives different loss" do
|
||||
flat = Eme.path_loss_db(10_368.0, 384_400.0)
|
||||
band_aware = Eme.path_loss_db(10_368.0, 384_400.0, Eme.moon_albedo(10_368.0))
|
||||
|
||||
# At 10 GHz band_aware uses ρ = 0.090 vs flat ρ = 0.065 →
|
||||
# band_aware should be ~1.4 dB LOWER (less loss because the
|
||||
# moon reflects more at cm-wavelengths).
|
||||
assert band_aware < flat
|
||||
assert_in_delta flat - band_aware, 1.4, 0.2
|
||||
end
|
||||
end
|
||||
|
||||
describe "received_power_dbm/1" do
|
||||
test "classic 1296 MHz / 1 kW / 45 dBi case lands at ~ -145 dBm" do
|
||||
# 30 dBm + 45 dBi + 45 dBi - 271 dB ≈ -151 dBm (weak but
|
||||
# workable for a CW operator with a cooled LNA). 1 kW = 60 dBm.
|
||||
p =
|
||||
Eme.received_power_dbm(
|
||||
tx_power_dbm: 60.0,
|
||||
tx_gain_dbi: 45.0,
|
||||
rx_gain_dbi: 45.0,
|
||||
freq_mhz: 1_296.0,
|
||||
distance_km: 384_400.0
|
||||
)
|
||||
|
||||
assert_in_delta p, -121.0, 1.0
|
||||
end
|
||||
|
||||
test "every dB added to gain adds 1 dB to received power (linearity)" do
|
||||
base =
|
||||
Eme.received_power_dbm(
|
||||
tx_power_dbm: 50.0,
|
||||
tx_gain_dbi: 30.0,
|
||||
rx_gain_dbi: 30.0,
|
||||
freq_mhz: 1_296.0,
|
||||
distance_km: 384_400.0
|
||||
)
|
||||
|
||||
bumped =
|
||||
Eme.received_power_dbm(
|
||||
tx_power_dbm: 50.0,
|
||||
tx_gain_dbi: 31.0,
|
||||
rx_gain_dbi: 30.0,
|
||||
freq_mhz: 1_296.0,
|
||||
distance_km: 384_400.0
|
||||
)
|
||||
|
||||
assert_in_delta bumped - base, 1.0, 0.001
|
||||
end
|
||||
end
|
||||
|
||||
describe "doppler_shift_hz/2" do
|
||||
test "zero radial velocity gives zero shift" do
|
||||
assert Eme.doppler_shift_hz(1_296.0, 0.0) == 0.0
|
||||
end
|
||||
|
||||
test "receding Moon (+v) produces a negative (down-shifted) return" do
|
||||
# +1 m/s = +0.001 km/s receding at 1296 MHz →
|
||||
# Δf = -2 * 1296e6 * 1 / 3e8 ≈ -8.65 Hz.
|
||||
dop = Eme.doppler_shift_hz(1_296.0, 0.001)
|
||||
assert dop < 0.0
|
||||
assert_in_delta dop, -8.65, 0.05
|
||||
end
|
||||
|
||||
test "approaching Moon (−v) produces a positive (up-shifted) return" do
|
||||
dop = Eme.doppler_shift_hz(10_368.0, -0.5)
|
||||
assert dop > 0.0
|
||||
# Δf = +2 * 10.368e9 * 500 / 3e8 ≈ +34_560 Hz.
|
||||
assert_in_delta dop, 34_560.0, 100.0
|
||||
end
|
||||
|
||||
test "scales linearly with frequency at fixed radial velocity" do
|
||||
dop_1296 = Eme.doppler_shift_hz(1_296.0, 0.1)
|
||||
dop_10368 = Eme.doppler_shift_hz(10_368.0, 0.1)
|
||||
assert_in_delta dop_10368 / dop_1296, 10_368.0 / 1_296.0, 1.0e-9
|
||||
end
|
||||
|
||||
test "scales linearly with radial velocity at fixed frequency" do
|
||||
a = Eme.doppler_shift_hz(1_296.0, 0.2)
|
||||
b = Eme.doppler_shift_hz(1_296.0, 0.4)
|
||||
assert_in_delta b / a, 2.0, 1.0e-9
|
||||
end
|
||||
|
||||
property "sign is always opposite to radial_velocity (receding → down-shift)" do
|
||||
check all(
|
||||
f <- float(min: 50.0, max: 100_000.0),
|
||||
v <- float(min: -2.0, max: 2.0),
|
||||
v != 0.0
|
||||
) do
|
||||
dop = Eme.doppler_shift_hz(f, v)
|
||||
|
||||
if v > 0 do
|
||||
assert dop < 0
|
||||
else
|
||||
assert dop > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "noise_floor_dbm/2" do
|
||||
test "-174 dBm/Hz at room temperature (290 K) matches kTB textbook" do
|
||||
assert_in_delta Eme.noise_floor_dbm(1.0, 290.0), -174.0, 0.01
|
||||
end
|
||||
|
||||
test "+10 dB noise increase per decade of bandwidth" do
|
||||
base = Eme.noise_floor_dbm(100.0, 290.0)
|
||||
decade = Eme.noise_floor_dbm(1_000.0, 290.0)
|
||||
assert_in_delta decade - base, 10.0, 0.01
|
||||
end
|
||||
|
||||
test "halving Tsys drops noise floor by ~3 dB" do
|
||||
hot = Eme.noise_floor_dbm(2500.0, 290.0)
|
||||
cold = Eme.noise_floor_dbm(2500.0, 145.0)
|
||||
assert_in_delta hot - cold, 3.01, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
describe "snr_db/1" do
|
||||
test "own-echo CW SNR on a decent 1296 MHz station comes out positive" do
|
||||
# 500 W (57 dBm), 3 m dish (~30 dBi), 100 Hz CW bandwidth, 100 K
|
||||
# system noise. Should be ~12 dB above thermal — plenty for CW.
|
||||
snr =
|
||||
Eme.snr_db(
|
||||
tx_power_dbm: 57.0,
|
||||
tx_gain_dbi: 32.0,
|
||||
rx_gain_dbi: 32.0,
|
||||
freq_mhz: 1_296.0,
|
||||
distance_km: 384_400.0,
|
||||
bandwidth_hz: 100.0,
|
||||
t_sys_k: 100.0
|
||||
)
|
||||
|
||||
assert snr > 5.0 and snr < 30.0
|
||||
end
|
||||
|
||||
test "narrowing detection bandwidth improves SNR one-for-one in dB" do
|
||||
opts = [
|
||||
tx_power_dbm: 50.0,
|
||||
tx_gain_dbi: 30.0,
|
||||
rx_gain_dbi: 30.0,
|
||||
freq_mhz: 1_296.0,
|
||||
distance_km: 384_400.0,
|
||||
t_sys_k: 290.0
|
||||
]
|
||||
|
||||
snr_wide = Eme.snr_db(Keyword.put(opts, :bandwidth_hz, 2500.0))
|
||||
snr_narrow = Eme.snr_db(Keyword.put(opts, :bandwidth_hz, 25.0))
|
||||
|
||||
# 100× narrower → 20 dB less noise → 20 dB more SNR.
|
||||
assert_in_delta snr_narrow - snr_wide, 20.0, 0.01
|
||||
end
|
||||
end
|
||||
end
|
||||
58
test/microwaveprop_web/live/eme_live_test.exs
Normal file
58
test/microwaveprop_web/live/eme_live_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule MicrowavepropWeb.EmeLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "GET /eme" do
|
||||
test "renders the empty form when no source is supplied", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme")
|
||||
|
||||
assert html =~ "EME Calculator"
|
||||
assert html =~ "Station (callsign, grid, or"
|
||||
assert html =~ "Calculate"
|
||||
# No aim readout yet.
|
||||
refute html =~ "Antenna aim"
|
||||
end
|
||||
|
||||
test "renders the live Moon aim + link budget when a grid source is provided", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=EM13&band=1296&tx_power_dbm=50&tx_gain_dbi=30")
|
||||
|
||||
assert html =~ "Antenna aim"
|
||||
assert html =~ "Link budget"
|
||||
assert html =~ "EM13"
|
||||
assert html =~ "Azimuth"
|
||||
assert html =~ "Elevation"
|
||||
assert html =~ "Slant range"
|
||||
# Path loss should be in the high 200s (dB) for 1296 MHz.
|
||||
assert html =~ "dB"
|
||||
end
|
||||
|
||||
test "submitting the form patches the URL with the supplied params", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/eme")
|
||||
|
||||
view
|
||||
|> form("form",
|
||||
source: "EM13",
|
||||
band: "432",
|
||||
tx_power_dbm: "60",
|
||||
tx_gain_dbi: "25",
|
||||
bandwidth_hz: "100",
|
||||
t_sys_k: "150"
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
# Flush any pending assign updates to catch redirects/patches.
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "EM13"
|
||||
assert html =~ "Antenna aim"
|
||||
end
|
||||
|
||||
test "coordinate pair 'lat,lon' resolves without touching the callsign API", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=32.9,-97.0&band=1296")
|
||||
assert html =~ "Antenna aim"
|
||||
assert html =~ "32.9"
|
||||
assert html =~ "-97.0"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue