From 36adf875b730226117aec1d8930fac492fdd03ba Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 12:36:06 -0500 Subject: [PATCH] =?UTF-8?q?Add=2050/144/222=20MHz=20bands,=20rename=20440?= =?UTF-8?q?=E2=86=92432?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/propagation/band_config.ex | 67 +++++++++++++++++-- lib/microwaveprop/propagation/sporadic_e.ex | 2 +- lib/microwaveprop/radio/band_resolver.ex | 37 +++++++++- lib/microwaveprop/radio/contact.ex | 5 +- lib/microwaveprop_web/live/path_live.ex | 2 +- .../propagation/band_config_test.exs | 18 ++--- .../propagation/sporadic_e_test.exs | 6 +- test/microwaveprop/propagation_test.exs | 4 +- test/microwaveprop/radio/adif_import_test.exs | 13 +++- .../radio/contact_submission_test.exs | 6 +- 10 files changed, 131 insertions(+), 29 deletions(-) diff --git a/lib/microwaveprop/propagation/band_config.ex b/lib/microwaveprop/propagation/band_config.ex index 4eebd0c9..7d6f9519 100644 --- a/lib/microwaveprop/propagation/band_config.ex +++ b/lib/microwaveprop/propagation/band_config.ex @@ -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, diff --git a/lib/microwaveprop/propagation/sporadic_e.ex b/lib/microwaveprop/propagation/sporadic_e.ex index 4375220d..bfd6cf5a 100644 --- a/lib/microwaveprop/propagation/sporadic_e.ex +++ b/lib/microwaveprop/propagation/sporadic_e.ex @@ -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). diff --git a/lib/microwaveprop/radio/band_resolver.ex b/lib/microwaveprop/radio/band_resolver.ex index 02f5310e..56c48db5 100644 --- a/lib/microwaveprop/radio/band_resolver.ex +++ b/lib/microwaveprop/radio/band_resolver.ex @@ -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 diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index 92141959..037a8e98 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -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 diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index 2750b1c3..fe9afddb 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -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) diff --git a/test/microwaveprop/propagation/band_config_test.exs b/test/microwaveprop/propagation/band_config_test.exs index ef3c70ee..d308dccf 100644 --- a/test/microwaveprop/propagation/band_config_test.exs +++ b/test/microwaveprop/propagation/band_config_test.exs @@ -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 diff --git a/test/microwaveprop/propagation/sporadic_e_test.exs b/test/microwaveprop/propagation/sporadic_e_test.exs index 6bfb873e..f3d750f9 100644 --- a/test/microwaveprop/propagation/sporadic_e_test.exs +++ b/test/microwaveprop/propagation/sporadic_e_test.exs @@ -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 diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs index e81191c6..b462961a 100644 --- a/test/microwaveprop/propagation_test.exs +++ b/test/microwaveprop/propagation_test.exs @@ -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 diff --git a/test/microwaveprop/radio/adif_import_test.exs b/test/microwaveprop/radio/adif_import_test.exs index f9092d87..0e250c4d 100644 --- a/test/microwaveprop/radio/adif_import_test.exs +++ b/test/microwaveprop/radio/adif_import_test.exs @@ -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 = """ - W5XDK5TREM00EM12144.200CW20260328180000 + W5XDK5TREM00EM1214.200CW20260328180000 """ 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 = """ + W5XDK5TREM00EM12144.200CW20260328180000 + """ + + 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, _} = diff --git a/test/microwaveprop/radio/contact_submission_test.exs b/test/microwaveprop/radio/contact_submission_test.exs index 61599306..9edf5173 100644 --- a/test/microwaveprop/radio/contact_submission_test.exs +++ b/test/microwaveprop/radio/contact_submission_test.exs @@ -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"