190 lines
5.9 KiB
Go
190 lines
5.9 KiB
Go
// Package rda resolves a Russian callsign to the RDA district it operated from
|
|
// ON A GIVEN DAY.
|
|
//
|
|
// # Why the day matters
|
|
//
|
|
// A Russian station can move district, and an expedition operates from a dozen
|
|
// in a fortnight. Asking "which district is this callsign in?" therefore has no
|
|
// single answer, and answering it with today's district would rewrite a correct
|
|
// three-year-old contact into a false one — silently, with nothing downstream
|
|
// able to tell. Every lookup here takes a date for that reason.
|
|
//
|
|
// # The data
|
|
//
|
|
// Compiled from RDA.mdb, the reference database distributed for the award
|
|
// (58 188 callsigns). Two shapes live in its RDA column and they mean different
|
|
// things:
|
|
//
|
|
// KK-08 the district the station operates from,
|
|
// with no history — the compiler knows of
|
|
// no change for this call
|
|
// <2021-07-29,2021-07-29,TO-16>… dated activity records, one per period
|
|
//
|
|
// 43 294 callsigns are of the first kind and 14 894 of the second. A dated
|
|
// record covering the QSO day is an ANSWER; a bare current district is an
|
|
// assumption, true for a station that never moved and wrong for one that did.
|
|
// Lookup reports which it gave, and the caller decides whether an assumption is
|
|
// good enough — see Match.Dated.
|
|
//
|
|
// # Regenerating
|
|
//
|
|
// There is no updater: the source is an Access database, and reading one needs
|
|
// cgo, which this project does not use. It is converted once, by hand, and the
|
|
// result embedded. To redo it with a newer RDA.mdb (PowerShell, needs the ACE
|
|
// OLEDB provider that ships with Office):
|
|
//
|
|
// $conn = New-Object System.Data.OleDb.OleDbConnection(
|
|
// "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=RDA.mdb;")
|
|
// SELECT CallSign, RDA, DXCCID FROM [RDACallsigns] ORDER BY CallSign
|
|
//
|
|
// writing one line per callsign as
|
|
//
|
|
// CALL;DXCC;CURRENT;YYYYMMDD,YYYYMMDD,DIST;YYYYMMDD,YYYYMMDD,DIST…
|
|
//
|
|
// where CURRENT may be empty, then gzip it to rdadb.txt.gz.
|
|
package rda
|
|
|
|
import (
|
|
"bufio"
|
|
"compress/gzip"
|
|
"embed"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed rdadb.txt.gz
|
|
var files embed.FS
|
|
|
|
// span is one dated activity record: the district, and the inclusive day range
|
|
// it applies to as YYYYMMDD integers (comparing those is comparing dates, with
|
|
// no timezone to get wrong).
|
|
type span struct {
|
|
from, to uint32
|
|
district string
|
|
}
|
|
|
|
type entry struct {
|
|
dxcc int
|
|
current string // district with no history; "" when only dated records exist
|
|
spans []span
|
|
}
|
|
|
|
var (
|
|
once sync.Once
|
|
byCal map[string]entry
|
|
loadN int
|
|
)
|
|
|
|
// Match is what a lookup found.
|
|
type Match struct {
|
|
// District is the RDA reference, e.g. "KE-29".
|
|
District string
|
|
// Dated is true when a dated activity record covered the day asked for.
|
|
//
|
|
// False means the district came from the callsign's CURRENT entry, which
|
|
// carries no dates at all. For the great majority of stations that is the
|
|
// same thing — the database records a history precisely for the ones that
|
|
// moved — but it is an assumption and not a fact, and a caller writing into
|
|
// a log years old should be told which of the two it is getting.
|
|
Dated bool
|
|
// DXCC is the entity the database files the callsign under (15, 54 or 126).
|
|
DXCC int
|
|
}
|
|
|
|
func load() {
|
|
byCal = make(map[string]entry, 60000)
|
|
f, err := files.Open("rdadb.txt.gz")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
zr, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer zr.Close()
|
|
sc := bufio.NewScanner(zr)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // one expedition's history is long
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, ";")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
e := entry{current: parts[2]}
|
|
e.dxcc, _ = strconv.Atoi(parts[1])
|
|
for _, p := range parts[3:] {
|
|
f := strings.Split(p, ",")
|
|
if len(f) != 3 {
|
|
continue
|
|
}
|
|
from, err1 := strconv.ParseUint(f[0], 10, 32)
|
|
to, err2 := strconv.ParseUint(f[1], 10, 32)
|
|
if err1 != nil || err2 != nil {
|
|
continue
|
|
}
|
|
e.spans = append(e.spans, span{from: uint32(from), to: uint32(to), district: f[2]})
|
|
}
|
|
byCal[parts[0]] = e
|
|
loadN++
|
|
}
|
|
}
|
|
|
|
// Count returns how many callsigns the database holds (0 before the first use).
|
|
func Count() int {
|
|
once.Do(load)
|
|
return loadN
|
|
}
|
|
|
|
// Lookup resolves a callsign on a given day.
|
|
//
|
|
// The match is EXACT on the callsign, deliberately. A suffix is not decoration:
|
|
// RA3YG/P is somewhere other than RA3YG by definition, and quietly falling back
|
|
// to the base call would answer with the home district for a contact made from
|
|
// a field — which is the precise error this package exists to avoid.
|
|
func Lookup(call string, day time.Time) (Match, bool) {
|
|
once.Do(load)
|
|
c := strings.ToUpper(strings.TrimSpace(call))
|
|
if c == "" {
|
|
return Match{}, false
|
|
}
|
|
e, ok := byCal[c]
|
|
if !ok {
|
|
return Match{}, false
|
|
}
|
|
d := ymd(day)
|
|
// A dated record wins whenever one covers the day. Several can overlap — an
|
|
// expedition working two districts in a day — and the FIRST is taken rather
|
|
// than a guess between them: the database lists them in the order its
|
|
// compiler recorded them, and inventing a tie-break here would be inventing
|
|
// data.
|
|
if d != 0 {
|
|
for _, s := range e.spans {
|
|
if d >= s.from && d <= s.to {
|
|
return Match{District: s.district, Dated: true, DXCC: e.dxcc}, true
|
|
}
|
|
}
|
|
}
|
|
if e.current != "" {
|
|
return Match{District: e.current, Dated: false, DXCC: e.dxcc}, true
|
|
}
|
|
// Dated records exist but none covers this day. That is an answer in itself
|
|
// — the station was somewhere the database does not know about — and it is
|
|
// reported as "not found" rather than by handing back a neighbouring period.
|
|
return Match{}, false
|
|
}
|
|
|
|
// ymd turns a time into YYYYMMDD, or 0 for the zero time.
|
|
func ymd(t time.Time) uint32 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
t = t.UTC()
|
|
return uint32(t.Year())*10000 + uint32(t.Month())*100 + uint32(t.Day())
|
|
}
|