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:
+22
-17
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user