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.
This commit is contained in:
Graham McIntire 2026-04-16 12:36:06 -05:00
parent 90df646c94
commit 36adf875b7
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 131 additions and 29 deletions

View file

@ -54,8 +54,37 @@ defmodule Microwaveprop.Propagation.BandConfig do
# humidity is :beneficial. The difference from 902+ is mostly in
# the achievable range — a 2m duct can reach 2500+ km under
# exceptional conditions (e.g. documented Hawaii ↔ California
# openings). These do NOT capture sporadic-E or meteor scatter,
# which need ionospheric data we don't yet ingest.
# openings). These do NOT capture sporadic-E, F2, or meteor scatter,
# which need ionospheric data we don't yet ingest. 6m is especially
# affected — most 6m DX is via Es / F2, not tropo.
50 => %{
freq_mhz: 50,
label: "50 MHz",
o2_db_km: 0.0,
h2o_coeff: 0.0,
humidity_effect: :beneficial,
humidity_penalty: 0.0,
rain_k: 0.0,
rain_alpha: 1.0,
seasonal_base: %{
1 => 30,
2 => 32,
3 => 25,
4 => 50,
5 => 70,
6 => 95,
7 => 95,
8 => 70,
9 => 65,
10 => 70,
11 => 60,
12 => 25
},
seasonal_adj: %{},
typical_range_km: 400,
extended_range_km: 1500,
exceptional_range_km: 4000
},
144 => %{
freq_mhz: 144,
label: "144 MHz",
@ -84,9 +113,37 @@ defmodule Microwaveprop.Propagation.BandConfig do
extended_range_km: 900,
exceptional_range_km: 2500
},
440 => %{
freq_mhz: 440,
label: "440 MHz",
222 => %{
freq_mhz: 222,
label: "222 MHz",
o2_db_km: 0.0,
h2o_coeff: 0.0,
humidity_effect: :beneficial,
humidity_penalty: 0.0,
rain_k: 0.0,
rain_alpha: 1.0,
seasonal_base: %{
1 => 38,
2 => 40,
3 => 22,
4 => 55,
5 => 68,
6 => 90,
7 => 95,
8 => 75,
9 => 78,
10 => 88,
11 => 78,
12 => 25
},
seasonal_adj: %{},
typical_range_km: 320,
extended_range_km: 800,
exceptional_range_km: 2000
},
432 => %{
freq_mhz: 432,
label: "432 MHz",
o2_db_km: 0.0,
h2o_coeff: 0.0,
humidity_effect: :beneficial,

View file

@ -22,7 +22,7 @@ defmodule Microwaveprop.Propagation.SporadicE do
- 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.
- 222/432 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).

View file

@ -13,12 +13,34 @@ defmodule Microwaveprop.Radio.BandResolver do
# Keep in sync with:
# * `Microwaveprop.Radio.Contact.@allowed_bands`
# * `Microwaveprop.Propagation.BandConfig.@band_configs`
@allowed_bands [902, 1296, 2304, 3456, 5760, 10_000, 24_000, 47_000, 68_000, 75_000, 122_000, 134_000, 241_000]
@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,
@ -48,8 +70,8 @@ defmodule Microwaveprop.Radio.BandResolver do
10368, "10368.000") returns the nearest allowed band
* pre-canonicalized band values (902, "902") passes through
Returns `nil` for unparseable input and for sub-900 MHz values (which
are below the site's lowest supported microwave band).
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
@ -94,6 +116,15 @@ defmodule Microwaveprop.Radio.BandResolver do
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

View file

@ -64,7 +64,10 @@ defmodule Microwaveprop.Radio.Contact do
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and
# Microwaveprop.Radio.AdifImport.@allowed_bands.
@allowed_bands Enum.map(~w(902 1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
@allowed_bands Enum.map(
~w(50 144 222 432 902 1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000),
&Decimal.new/1
)
@spec submission_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def submission_changeset(contact, attrs) do

View file

@ -302,7 +302,7 @@ defmodule MicrowavepropWeb.PathLive do
# Returns nil when there's no usable readout, or a map describing the
# nearest ionosonde's current foEs + the computed Es score for
# (band, distance). Only VHF bands get the readout.
defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [144, 440] do
defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
case Ionosphere.nearest_foes(midlat, midlon) do
{:ok, obs} ->
es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)

View file

@ -4,8 +4,10 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
alias Microwaveprop.Propagation.BandConfig
@all_freqs [
50,
144,
440,
222,
432,
902,
1_296,
2_304,
@ -136,10 +138,10 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
assert config.exceptional_range_km >= 2000
end
test "returns config for 440 MHz (70cm UHF)" do
config = BandConfig.get(440)
assert config.freq_mhz == 440
assert config.label == "440 MHz"
test "returns config for 432 MHz (70cm UHF)" do
config = BandConfig.get(432)
assert config.freq_mhz == 432
assert config.label == "432 MHz"
assert config.humidity_effect == :beneficial
assert config.humidity_penalty == 0.0
assert config.rain_k == 0.0
@ -183,9 +185,9 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
end
describe "all_bands/0" do
test "returns 21 bands (13 original + 142/145/288/322/403/411 GHz + 144/440 MHz)" do
test "returns 23 bands (13 original + 142/145/288/322/403/411 GHz + 50/144/222/432 MHz)" do
bands = BandConfig.all_bands()
assert length(bands) == 21
assert length(bands) == 23
end
test "bands are sorted by frequency" do
@ -361,7 +363,7 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
end
test "sub-10 GHz bands are beneficial, 24+ GHz are harmful" do
beneficial = [144, 440, 902, 1_296, 2_304, 3_456, 5_760, 10_000]
beneficial = [50, 144, 222, 432, 902, 1_296, 2_304, 3_456, 5_760, 10_000]
harmful = @all_freqs -- beneficial
for freq <- beneficial do

View file

@ -68,10 +68,10 @@ defmodule Microwaveprop.Propagation.SporadicETest do
assert score >= 50
end
test "scores 440 MHz as NONE even at extreme foEs (sporadic-E does not reach 70cm)" do
# foEs would need to be ~48 MHz to get 440 MHz via single-hop Es
test "scores 432 MHz as NONE even at extreme foEs (sporadic-E does not reach 70cm)" do
# foEs would need to be ~47 MHz to get 432 MHz via single-hop Es
# — way beyond any physically observed value.
assert SporadicE.es_score(25.0, 440, 2000) == 0
assert SporadicE.es_score(25.0, 432, 2000) == 0
end
test "higher foEs strictly increases the score at a given band/distance (monotonic)" do

View file

@ -32,7 +32,7 @@ defmodule Microwaveprop.PropagationTest do
valid_time = ~U[2026-07-15 13:00:00Z]
results = Propagation.score_grid_point(hrrr_profile, valid_time, 33.0, -97.0)
assert length(results) == 21
assert length(results) == 23
Enum.each(results, fn result ->
assert result.score >= 0 and result.score <= 100
@ -98,7 +98,7 @@ defmodule Microwaveprop.PropagationTest do
valid_time = ~U[2026-07-15 13:00:00Z]
results = Propagation.score_grid_point(hrrr_profile_without_array, valid_time, 33.0, -97.0)
assert length(results) == 21
assert length(results) == 23
Enum.each(results, fn result ->
assert result.score >= 0 and result.score <= 100

View file

@ -149,9 +149,9 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert length(preview.invalid) == 1
end
test "skips non-microwave contacts" do
test "skips HF contacts" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>144.200<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:6>14.200<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
@ -159,6 +159,15 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert length(preview.invalid) == 1
end
test "accepts 2m contacts" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>144.200<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [%{attrs: %{"band" => "144"}}] = preview.valid
end
test "deduplicates against existing DB contacts" do
# Insert one via CSV import path
{:ok, _} =

View file

@ -78,13 +78,13 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
end
test "rejects invalid band" do
attrs = Map.put(@valid_attrs, :band, "144")
attrs = Map.put(@valid_attrs, :band, "28")
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert errors_on(changeset).band
end
test "accepts valid microwave bands" do
for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do
test "accepts valid bands" do
for band <- ~w(50 144 222 432 902 1296 2304 3456 5760 10000 24000 47000 75000) do
attrs = Map.put(@valid_attrs, :band, band)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert changeset.valid?, "Expected band #{band} to be valid"