Normalize ADIF MODE/SUBMODE and document the mapping

- AdifImport.normalize_mode/2 maps ADIF MODE (and SUBMODE when present)
  onto the fixed set of modes the site accepts: CW/SSB/FM/FT8/FT4/Q65.
  SUBMODE takes precedence so `MODE=MFSK, SUBMODE=FT8` correctly imports
  as FT8. USB/LSB/DSB all become SSB; NBFM/WFM become FM.
- Unknown modes (RTTY, PSK31, etc.) map to nil so the row still imports
  — mode is optional on the submission path now.
- AdifImport.mode_mapping_reference/0 returns the user-facing list of
  recognized inputs and their mappings.
- Submit page's ADIF tab renders that list in a table so users can see
  upfront what will happen to their file before uploading.
This commit is contained in:
Graham McIntire 2026-04-12 13:15:30 -05:00
parent 038f9ad54d
commit eb1e80958c
3 changed files with 152 additions and 1 deletions

View file

@ -32,6 +32,73 @@ defmodule Microwaveprop.Radio.AdifImport do
# Our allowed band centers in MHz, sorted ascending for nearest-match
@allowed_bands [1296, 2304, 3456, 5760, 10_000, 24_000, 47_000, 68_000, 75_000, 122_000, 134_000, 241_000]
# Map ADIF MODE (and MODE+SUBMODE combinations) onto the fixed set of modes
# the site accepts: CW / SSB / FM / FT8 / FT4 / Q65. Unknown values map to
# nil — mode is optional on submission so unrecognized contacts still
# import, just without a mode label.
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
@mode_map %{
"CW" => "CW",
"PCW" => "CW",
"SSB" => "SSB",
"USB" => "SSB",
"LSB" => "SSB",
"DSB" => "SSB",
"FM" => "FM",
"NBFM" => "FM",
"WFM" => "FM",
"FT8" => "FT8",
"FT4" => "FT4",
"Q65" => "Q65"
}
@doc """
Every ADIF MODE / SUBMODE combination we recognize, in display order.
Used by the upload page to show users what will happen to their data
before they commit the import.
"""
@spec mode_mapping_reference() :: [{String.t(), String.t()}]
def mode_mapping_reference do
[
{"CW / PCW", "CW"},
{"SSB / USB / LSB / DSB", "SSB"},
{"FM / NBFM / WFM", "FM"},
{"FT8 (or MFSK+SUBMODE=FT8)", "FT8"},
{"FT4 (or MFSK+SUBMODE=FT4)", "FT4"},
{"Q65 (or MFSK+SUBMODE=Q65)", "Q65"},
{"anything else", "(blank — row still imports)"}
]
end
@doc """
Normalize an ADIF MODE / SUBMODE pair to one of our allowed modes or nil.
ADIF convention: digital modes are often reported as `MODE=MFSK` with a
specific `SUBMODE` (FT8, FT4, Q65, JT65, ), so we prefer SUBMODE when it
matches one of our allowed values. For everything else we look up the raw
MODE in `@mode_map`; unknown values return nil.
"""
@spec normalize_mode(String.t() | nil, String.t() | nil) :: String.t() | nil
def normalize_mode(mode, submode) do
cond do
allowed = lookup_allowed(submode) -> allowed
allowed = lookup_allowed(mode) -> allowed
true -> nil
end
end
defp lookup_allowed(nil), do: nil
defp lookup_allowed(raw) do
normalized = raw |> to_string() |> String.trim() |> String.upcase()
cond do
normalized == "" -> nil
normalized in @allowed_modes -> normalized
true -> Map.get(@mode_map, normalized)
end
end
@doc """
Parse ADIF string, validate, and deduplicate. Returns the same preview
shape as `CsvImport.preview/2`.
@ -144,7 +211,7 @@ defmodule Microwaveprop.Radio.AdifImport do
station2 = fields["CALL"]
grid1 = normalize_grid(fields["MY_GRIDSQUARE"])
grid2 = normalize_grid(fields["GRIDSQUARE"])
mode = fields["MODE"]
mode = normalize_mode(fields["MODE"], fields["SUBMODE"])
band = resolve_band(fields)
timestamp = parse_adif_datetime(fields["QSO_DATE"], fields["TIME_ON"])

View file

@ -494,6 +494,8 @@ defmodule MicrowavepropWeb.SubmitLive do
end
defp adif_upload_form(assigns) do
assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference())
~H"""
<div class="space-y-6">
<div class="alert text-sm">
@ -515,6 +517,31 @@ defmodule MicrowavepropWeb.SubmitLive do
</div>
</div>
<div class="alert text-sm">
<div class="w-full">
<p class="font-semibold mb-2">Mode handling</p>
<p class="mb-2 text-base-content/70">
ADIF modes are normalized to the six modes we track. SUBMODE takes
precedence when present (e.g. <code>MODE=MFSK, SUBMODE=FT8</code>
imports as FT8).
</p>
<table class="table table-xs mt-2">
<thead>
<tr>
<th>ADIF value</th>
<th>Stored as</th>
</tr>
</thead>
<tbody>
<tr :for={{adif, mapped} <- @mode_mapping}>
<td><code>{adif}</code></td>
<td><code>{mapped}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
<form
id="adif-upload-form"
phx-submit="upload_adif"

View file

@ -230,6 +230,63 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert row.attrs["station1"] == "W5XD"
end
test "normalizes USB / LSB / DSB to SSB" do
for sideband <- ~w(USB LSB DSB) do
adif =
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:#{byte_size(sideband)}>#{sideband}<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>"
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
assert row.attrs["mode"] == "SSB", "expected #{sideband} to map to SSB"
end
end
test "normalizes NBFM / WFM to FM" do
for variant <- ~w(NBFM WFM) do
adif =
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:#{byte_size(variant)}>#{variant}<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>"
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
assert row.attrs["mode"] == "FM"
end
end
test "uses SUBMODE when MODE is a generic digital mode" do
# ADIF convention: digital modes are reported as MODE=MFSK, SUBMODE=FT8
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:4>MFSK<SUBMODE:3>FT8<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
assert row.attrs["mode"] == "FT8"
end
test "SUBMODE takes precedence when both are allowed values" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:3>MFSK<SUBMODE:3>FT4<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
assert row.attrs["mode"] == "FT4"
end
test "unknown modes are stored as nil so the row still imports" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:4>RTTY<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
assert row.attrs["mode"] == nil
end
test "missing MODE imports as nil" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
assert row.attrs["mode"] == nil
end
test "parses file with no header at all" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>