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