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:
parent
90df646c94
commit
36adf875b7
10 changed files with 131 additions and 29 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, _} =
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue