prop/lib/microwaveprop/radio/band_resolver.ex
Graham McIntire f93f5c9a57
Rename 9cm band 3456 → 3400
The US 9cm amateur allocation was cut from 3300-3500 to 3300-3450 MHz
in 2020, so 3456 (the legacy weak-signal calling frequency) is no
longer in-band. Move the canonical key to 3400, migrate existing
contacts, and keep 3456 input resolving to 3400 via the
nearest-band snap so historical ADIF/CSV logs still import cleanly.
2026-04-16 12:40:38 -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,
3400,
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_400,
"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