prop/lib/microwaveprop/radio/band_resolver.ex
Graham McIntire 36adf875b7
Add 50/144/222 MHz bands, rename 440→432
Expands submittable-contact bands to include 6m, 2m, 1.25m, and 70cm
(as 432 rather than the old 440 placeholder). Each new band gets an
explicit allocation window in BandResolver.nearest_band so ADIF FREQ
fields near the amateur allocations resolve correctly while 60-900 MHz
frequencies outside those windows are still rejected. Microwave
(>= 900 MHz) snapping is unchanged — nearest-band match across the
full @allowed_bands list.

Also adds BandConfig entries for 50 and 222 (tropo-only config, same
pattern as 144/432). Sporadic-E / F2 / meteor scatter modeling is not
yet in scope — ionosphere data is only used to compute an Es readout
on /path for 50/144/222/432.
2026-04-16 12:36:10 -05:00

133 lines
3.6 KiB
Elixir

defmodule Microwaveprop.Radio.BandResolver do
@moduledoc """
Converts user-supplied band input — ADIF wavelength labels ("33cm",
"13cm"), numeric frequencies in MHz ("903.100", "10368"), or bare
channel numbers ("902", "1296") — into one of the site's canonical
MHz values.
Used by `Microwaveprop.Radio.AdifImport`, `Microwaveprop.Radio.CsvImport`,
and the `SubmitLive` manual form so all four entry points agree on
what "33cm" means.
"""
# Keep in sync with:
# * `Microwaveprop.Radio.Contact.@allowed_bands`
# * `Microwaveprop.Propagation.BandConfig.@band_configs`
@allowed_bands [
50,
144,
222,
432,
902,
1296,
2304,
3456,
5760,
10_000,
24_000,
47_000,
68_000,
75_000,
122_000,
134_000,
241_000
]
# ADIF amateur wavelength band labels → our MHz channel centers.
# The `normalize/1` helper strips whitespace and lower-cases before
# looking up, so "33 CM", "33cm", " 33Cm " all resolve.
@band_name_to_mhz %{
"6m" => 50,
"2m" => 144,
"1.25m" => 222,
"70cm" => 432,
"33cm" => 902,
"23cm" => 1_296,
"13cm" => 2_304,
"9cm" => 3_456,
"6cm" => 5_760,
"3cm" => 10_000,
"1.25cm" => 24_000,
"6mm" => 47_000,
"4mm" => 75_000,
"2.5mm" => 122_000,
"2mm" => 134_000,
"1mm" => 241_000
}
@doc "All MHz band centers the site accepts, ascending."
@spec allowed_bands() :: [pos_integer()]
def allowed_bands, do: @allowed_bands
@doc """
Resolve any user-supplied band input to a canonical MHz integer.
Accepts:
* ADIF wavelength labels ("33cm", "1.25cm", "6mm" — case/whitespace
insensitive)
* numeric frequencies in MHz as a string or number ("903.100",
10368, "10368.000") — returns the nearest allowed band
* pre-canonicalized band values (902, "902") — passes through
Returns `nil` for unparseable input and for values below the 6m
amateur band (<50 MHz).
"""
@spec resolve(term()) :: pos_integer() | nil
def resolve(nil), do: nil
def resolve(""), do: nil
def resolve(mhz) when is_integer(mhz), do: nearest_band(mhz * 1.0)
def resolve(mhz) when is_float(mhz), do: nearest_band(mhz)
def resolve(value) when is_binary(value) do
normalized = normalize(value)
cond do
mhz = @band_name_to_mhz[normalized] -> mhz
freq = parse_float(normalized) -> nearest_band(freq)
true -> nil
end
end
def resolve(_), do: nil
@doc "Return `resolve/1` as a string, or `nil`."
@spec resolve_as_string(term()) :: String.t() | nil
def resolve_as_string(value) do
case resolve(value) do
nil -> nil
mhz -> Integer.to_string(mhz)
end
end
defp normalize(s) do
s
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/, "")
end
defp parse_float(s) do
case Float.parse(s) do
{f, ""} -> f
{f, _rest} -> f
:error -> nil
end
end
# Amateur allocations: 6m is 50-54 MHz, 2m is 144-148 MHz, 1.25m is
# 222-225 MHz (US-only), 70cm is 420-450 MHz (US). Accept a little slop on
# each side to tolerate ADIF FREQ fields that are slightly off-band or
# informal "220" labels.
defp nearest_band(freq_mhz) when freq_mhz >= 44 and freq_mhz <= 60, do: 50
defp nearest_band(freq_mhz) when freq_mhz >= 140 and freq_mhz <= 150, do: 144
defp nearest_band(freq_mhz) when freq_mhz >= 218 and freq_mhz <= 226, do: 222
defp nearest_band(freq_mhz) when freq_mhz >= 420 and freq_mhz <= 450, do: 432
defp nearest_band(freq_mhz) when freq_mhz >= 900 do
Enum.min_by(@allowed_bands, &abs(&1 - freq_mhz))
end
defp nearest_band(_), do: nil
end