prop/lib/microwaveprop/propagation/moon_ephemeris.ex
Graham McIntire 622edee180
feat(propagation): per-contact mechanism classification
Classifies every contact's likely non-LOS propagation mechanism and
persists the result on contacts.propagation_mechanism. Mechanism is
determined in priority order:

  1. user_declared_prop_mode (ADIF PROP_MODE from the operator log)
  2. EME — moon-ephemeris check, ≥2m band, >1800 km path
  3. aurora — Kp≥5 + high-lat path, 50-432 MHz
  4. sporadic-E — foEs × 5 ≥ band_mhz, 400-2500 km path
  5. meteor_scatter — ±3 days of a shower peak, VHF/UHF
  6. rain_scatter — common-volume radar heavy rain, 5-11 GHz ≤800 km
  7. tropo_duct — HRRR native_best_duct ≥ band or ducting_detected
  8. line_of_sight — ≤50 km path
  9. troposcatter — default

Persisted via MechanismClassifyWorker (queue: :mechanism, unique on
contact_id). Submit-time enqueue path includes :mechanism by default;
BackfillEnqueueWorker cron now handles :mechanism alongside existing
types so prod continuously backfills any contact with
propagation_mechanism_status in (:pending, :queued, :failed). Also
added :radar to the cron's type list so common-volume radar backfill
runs automatically rather than only via `mix radar_backfill`.

New modules:
- Microwaveprop.Propagation.MoonEphemeris — Meeus low-precision moon
  position, accuracy ±1° — enough for the mutual-visibility EME test
- Microwaveprop.Propagation.MechanismClassifier — plug-in priority
  chain over the evidence map
- Microwaveprop.Workers.MechanismClassifyWorker — assembles inputs
  from HRRR / native profiles / common-volume radar / solar_indices /
  ionosonde + calls the classifier

ADIF importer now reads PROP_MODE into user_declared_prop_mode so
operator-tagged mechanisms (EME/ES/MS/RS/AS/AUR) become ground truth.
2026-04-18 10:42:08 -05:00

130 lines
3.9 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 Microwaveprop.Propagation.MoonEphemeris do
@moduledoc """
Approximate moon-position calculation for EME classification.
Accuracy target: ±1° — enough to decide "is the moon above the horizon
for both stations right now?" which is the EME feasibility test. We
use Jean Meeus's low-precision algorithm (Astronomical Algorithms,
chapter 47), good to ~10' over historical amateur contact timestamps.
No external ephemeris table — pure math so it works for any date.
"""
@deg_to_rad :math.pi() / 180.0
@rad_to_deg 180.0 / :math.pi()
@doc """
Returns the moon's altitude above the horizon in degrees for a given
observer lat/lon and UTC timestamp. Negative means the moon is below
the horizon (not reachable).
"""
@spec altitude_deg(number(), number(), DateTime.t()) :: float()
def altitude_deg(lat, lon, %DateTime{} = ts) do
{ra, dec} = moon_ra_dec(ts)
gst = greenwich_sidereal_hours(ts)
# Local hour angle in degrees
lst_deg = :math.fmod(gst * 15.0 + lon, 360.0)
hour_angle = lst_deg - ra * 15.0
lat_r = lat * @deg_to_rad
dec_r = dec * @deg_to_rad
ha_r = hour_angle * @deg_to_rad
sin_alt =
:math.sin(lat_r) * :math.sin(dec_r) +
:math.cos(lat_r) * :math.cos(dec_r) * :math.cos(ha_r)
sin_alt |> max(-1.0) |> min(1.0) |> :math.asin() |> Kernel.*(@rad_to_deg)
end
@doc """
`true` if the moon is above the horizon at the given observer + time.
"""
@spec above_horizon?(number(), number(), DateTime.t()) :: boolean()
def above_horizon?(lat, lon, %DateTime{} = ts), do: altitude_deg(lat, lon, ts) > 0.0
# — private math helpers below —
# Returns {right ascension (hours), declination (deg)} of the moon.
defp moon_ra_dec(%DateTime{} = ts) do
jd = julian_day(ts)
t = (jd - 2_451_545.0) / 36_525.0
# Mean orbital elements (Meeus §47, degrees).
l_prime = 218.3164477 + 481_267.88123421 * t
d = 297.8501921 + 445_267.1114034 * t
m = 357.5291092 + 35_999.0502909 * t
m_prime = 134.9633964 + 477_198.8675055 * t
f = 93.2720950 + 483_202.0175233 * t
# Longitude correction (leading term only — 6.289°).
lambda =
l_prime +
6.289 * sin_deg(m_prime) -
1.274 * sin_deg(2 * d - m_prime) +
0.658 * sin_deg(2 * d) -
0.186 * sin_deg(m)
# Latitude (β) leading term.
beta = 5.128 * sin_deg(f)
# Obliquity of the ecliptic.
eps = 23.439 - 0.0000004 * (jd - 2_451_545.0)
# Convert ecliptic (λ, β) → equatorial (α, δ).
lam_r = lambda * @deg_to_rad
beta_r = beta * @deg_to_rad
eps_r = eps * @deg_to_rad
sin_alpha = :math.sin(lam_r) * :math.cos(eps_r) - :math.tan(beta_r) * :math.sin(eps_r)
cos_alpha = :math.cos(lam_r)
alpha_r = :math.atan2(sin_alpha, cos_alpha)
sin_delta =
:math.sin(beta_r) * :math.cos(eps_r) +
:math.cos(beta_r) * :math.sin(eps_r) * :math.sin(lam_r)
delta_r = :math.asin(sin_delta)
ra_hours = :math.fmod(alpha_r * @rad_to_deg / 15.0 + 48.0, 24.0)
dec_deg = delta_r * @rad_to_deg
{ra_hours, dec_deg}
end
defp julian_day(%DateTime{year: y, month: m, day: d, hour: h, minute: mi, second: s}) do
{y2, m2} =
if m <= 2 do
{y - 1, m + 12}
else
{y, m}
end
a = div(y2, 100)
b = 2 - a + div(a, 4)
jd_day =
Float.floor(365.25 * (y2 + 4716)) +
Float.floor(30.6001 * (m2 + 1)) +
d + b - 1524.5
frac = (h + mi / 60 + s / 3600) / 24.0
jd_day + frac
end
defp greenwich_sidereal_hours(%DateTime{} = ts) do
jd = julian_day(ts)
t = (jd - 2_451_545.0) / 36_525.0
# Mean sidereal time at Greenwich, in hours.
gmst_sec =
67_310.54841 +
(876_600 * 3600 + 8_640_184.812866) * t +
0.093104 * t * t -
6.2e-6 * t * t * t
hours = gmst_sec / 3600.0
:math.fmod(:math.fmod(hours, 24.0) + 24.0, 24.0)
end
defp sin_deg(deg), do: :math.sin(deg * @deg_to_rad)
end