prop/lib/microwaveprop/propagation/sporadic_e.ex
Graham McIntire f632ea83bd
Sporadic-E physics module + Ionosphere.nearest_foes
Adds the missing link between the GIRO ionosonde data we just started
ingesting and the VHF/UHF band scoring:

* `Microwaveprop.Propagation.SporadicE` — ITU-R P.534-6 / Davies 1990
  thin-layer Es MUF (sec(i) · foEs with h=110 km), plus an `es_score/3`
  that maps (foEs, target band, hop distance) into a 0-100 single-hop
  propagation likelihood. Calibrated against literature: 50 MHz Es at
  2000 km needs foEs ≳ 5.5 MHz (routine summer); 144 MHz needs
  foEs ≳ 15.8 MHz (rare Jun/Jul peaks only); 440 MHz Es does not occur
  at physical foEs values. Multi-hop Es and Es-scatter are separate
  factors and explicitly out of scope.

* `Ionosphere.nearest_foes/3` — given a lat/lon, returns the latest
  observation from the nearest polled GIRO station (Millstone Hill or
  Alpena for now), with a configurable staleness cutoff (default 2h).
  Returns `{:error, :stale}` or `{:error, :no_data}` so callers can
  choose whether to apply the Es factor at all.

Neither is wired into the grid scorer yet — that's a separate commit
so the integration can be reviewed on its own.
2026-04-15 14:43:48 -05:00

84 lines
3.1 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.SporadicE do
@moduledoc """
Sporadic-E propagation physics for VHF scoring.
Given foEs (the E-layer critical frequency measured by an ionosonde,
in MHz) and a ground-range distance in km, computes the single-hop
Es MUF via the thin-layer obliquity factor from ITU-R P.534-6 /
Davies 1990 *Ionospheric Radio*, Eq. 6.26:
MUF_Es = foEs · sec(i)
where `sec(i) = √(1 + (D / (2h))²)` with the reflection height
`h = 110 km` (standard thin-E approximation).
The `es_score/3` function maps a `(foEs, target band, distance)`
triple into a 0100 propagation likelihood for **single-hop Es
only**. Multi-hop Es (which extends the range but needs consecutive
intense Es patches) and Es-scatter are not modelled here — those
are separate factors.
Calibration notes:
- 50 MHz Es at 2000 km needs foEs ≳ 5.5 MHz (routine summer Es).
- 144 MHz Es at 2000 km needs foEs ≳ 15.8 MHz (rare, observed during
major Jun/Jul peaks).
- 440 MHz Es does not occur at physically observed foEs values.
- Single-hop Es has a minimum useful range of ~500 km (steeper rays
escape the layer rather than reflect) and a maximum of ~2500 km
(the geometric horizon limit for a 110 km reflection height).
"""
# Standard E-layer reflection height for the thin-layer approximation.
@e_layer_height_km 110.0
@min_hop_km 500
@max_hop_km 2500
@doc """
Single-hop Es MUF in MHz. Returns 0.0 for nil/non-positive inputs.
"""
@spec single_hop_muf(number() | nil, number() | nil) :: float()
def single_hop_muf(nil, _), do: 0.0
def single_hop_muf(_, nil), do: 0.0
def single_hop_muf(fo_es_mhz, _) when fo_es_mhz <= 0, do: 0.0
def single_hop_muf(_, distance_km) when distance_km <= 0, do: 0.0
def single_hop_muf(fo_es_mhz, distance_km) do
sec_i = :math.sqrt(1 + :math.pow(distance_km / (2 * @e_layer_height_km), 2))
fo_es_mhz * sec_i
end
@doc """
Returns a 0100 score for single-hop Es propagation of `band_mhz`
over a ground-range `distance_km`, given the measured `fo_es_mhz`.
- Short paths (< 500 km) and long paths (> 2500 km) are outside the
single-hop window → 0.
- `nil` or 0 foEs → 0.
- Otherwise the score ramps with `MUF_Es / band_mhz`:
≥ 1.20 → 100 (comfortably above the critical threshold)
≥ 1.00 → 80 (just above, marginal Es)
≥ 0.90 → 50 (close, occasional fringe)
≥ 0.70 → 20 (weak fringe, unreliable)
below → 0
"""
@spec es_score(number() | nil, number(), number() | nil) :: integer()
def es_score(nil, _band, _distance), do: 0
def es_score(_fo_es, _band, nil), do: 0
def es_score(fo_es_mhz, _band, _distance) when fo_es_mhz <= 0, do: 0
def es_score(_fo_es, _band, distance_km) when distance_km < @min_hop_km or distance_km > @max_hop_km, do: 0
def es_score(fo_es_mhz, band_mhz, distance_km) do
muf = single_hop_muf(fo_es_mhz, distance_km)
ratio = muf / band_mhz
cond do
ratio >= 1.20 -> 100
ratio >= 1.00 -> 80
ratio >= 0.90 -> 50
ratio >= 0.70 -> 20
true -> 0
end
end
end