// 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()) }