chore: release v0.26.2
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// 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())
|
||||
}
|
||||
Reference in New Issue
Block a user