fix: recognise the microwave bands — 10 GHz resolved to no band at all

Reported on 3 cm. cat.BandFromHz stopped at 23 cm, so a 10 GHz frequency came
back EMPTY — and an empty band is not cosmetic: the QSO is logged without one,
counts in no award slot, and exports with no BAND field. The low end was short
too (2190m / 630m / 560m).

Four band tables had to be extended because four exist: the CAT one, the award
plan in app.go, the cluster spot classifier, and the frontend's. Plus the places
that LIST bands — the QSO editor and award-definition pickers (you could not set
3 cm by hand either), the statistics axis, and the award band ORDER, where a
missing band sorts to the end instead of in frequency order.

Ranges and names are ADIF 3.1.7, which is what an export has to carry.

A test now pins one frequency per band across the Go tables and asserts they
agree — that is what was missing, four hand-maintained copies with nothing
checking them. It also pins that an out-of-band frequency stays empty rather
than snapping to the nearest band, which would file a QSO under a band the
operator never used.
This commit is contained in:
2026-07-28 14:50:17 +02:00
parent add4b175fc
commit ad02583ef7
9 changed files with 193 additions and 49 deletions
+22 -17
View File
@@ -6,10 +6,11 @@
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
//
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
package award
import (
@@ -48,16 +49,16 @@ const (
// behaves as before.
type Def struct {
// --- Identity ---
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Description string `json:"description,omitempty"` // free text
Valid bool `json:"valid"` // award enabled
Protected bool `json:"protected,omitempty"` // shipped/locked award
URL string `json:"url,omitempty"` // award home page
Valid bool `json:"valid"` // award enabled
Protected bool `json:"protected,omitempty"` // shipped/locked award
URL string `json:"url,omitempty"` // award home page
DownloadURL string `json:"download_url,omitempty"` // reference-list source
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
Alias string `json:"alias,omitempty"`
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
@@ -66,7 +67,7 @@ type Def struct {
RefDisplay string `json:"ref_display,omitempty"`
// --- Type & matching ---
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Field string `json:"field"` // QSO field to scan (see fieldRaw)
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
@@ -89,14 +90,14 @@ type Def struct {
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
// --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
return out
}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
// Microwave. A band missing from this order sorts to the end of an award's
// band columns rather than in frequency order — and 3 cm was missing.
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
func sortedBands(m map[string]int) []string {
idx := map[string]int{}
+32
View File
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000
switch {
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
// which then cost them the band on the QSO and in every award count.
case mhz >= 0.1357 && mhz <= 0.1378:
return "2190m"
case mhz >= 0.472 && mhz <= 0.479:
return "630m"
case mhz >= 0.501 && mhz <= 0.504:
return "560m"
case mhz >= 1.8 && mhz <= 2.0:
return "160m"
case mhz >= 3.5 && mhz <= 4.0:
@@ -810,6 +818,30 @@ func BandFromHz(hz int64) string {
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm"
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
// actually work, and spot — resolved to an empty band: no band on the QSO, no
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
// which is what an export has to carry.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
}
return ""
}
+47 -25
View File
@@ -42,32 +42,32 @@ type ServerConfig struct {
// is emitted to the UI, so the table never has empty country cells
// flickering in for a few hundred ms.
type Spot struct {
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field
DXCall string `json:"dx_call"` // the DX station heard
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field
DXCall string `json:"dx_call"` // the DX station heard
FreqKHz float64 `json:"freq_khz"`
FreqHz int64 `json:"freq_hz"`
Band string `json:"band,omitempty"`
Comment string `json:"comment,omitempty"`
Locator string `json:"locator,omitempty"` // spotter grid (optional)
Locator string `json:"locator,omitempty"` // spotter grid (optional)
TimeUTC string `json:"time_utc,omitempty"`
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
Continent string `json:"continent,omitempty"` // 2-letter continent
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
Continent string `json:"continent,omitempty"` // 2-letter continent
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours
// ago is not on the air now.
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name
}
// State enumerates the per-server lifecycle.
@@ -122,14 +122,14 @@ type session struct {
onLine func(Line)
onStatus func()
mu sync.RWMutex
status ServerStatus
conn net.Conn
stopCh chan struct{}
doneCh chan struct{}
stopped bool // guards against double-stop on the same session
spotsCnt int
dbgN int // diagnostic: how many raw lines logged this connection
mu sync.RWMutex
status ServerStatus
conn net.Conn
stopCh chan struct{}
doneCh chan struct{}
stopped bool // guards against double-stop on the same session
spotsCnt int
dbgN int // diagnostic: how many raw lines logged this connection
}
// Manager owns N sessions, one per enabled server. Safe for concurrent
@@ -560,8 +560,8 @@ const (
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
// broadcast format, and therefore matched nothing at all:
//
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
// freq dxcall date time comment <spotter>
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
// freq dxcall date time comment <spotter>
//
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
// regex, and get dropped. The decimal point in the frequency is required — it is
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm"
// Microwave — a 10 GHz spot used to land with no band at all, so it could
// not be filtered, matched against the log, or shown on a band map.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
}
return ""
}