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.
130 lines
3.9 KiB
Elixir
130 lines
3.9 KiB
Elixir
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
|