This commit is contained in:
2026-06-07 12:50:04 +02:00
parent eb64b8f2f9
commit 9189f54df5
7 changed files with 126 additions and 9 deletions
+9 -2
View File
@@ -209,7 +209,13 @@ func parseEntityHeader(line string) *Entity {
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64)
e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64)
// cty.dat longitude is "+ for West" (e.g. France 2°E = -2.00, USA 92°W =
// +91.87). Negate it to the standard "+ for East" the rest of the app uses
// (grids, bearing/distance math), otherwise every cty.dat-derived azimuth and
// cluster distance is mirrored east↔west.
if lon, err := strconv.ParseFloat(strings.TrimSpace(parts[5]), 64); err == nil {
e.Lon = -lon
}
e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64)
if e.Name == "" {
return nil
@@ -241,7 +247,8 @@ func parsePrefix(s string, e *Entity) (prefixEntry, bool) {
lat, e1 := strconv.ParseFloat(a, 64)
lon, e2 := strconv.ParseFloat(b, 64)
if e1 == nil && e2 == nil {
out.latOverride, out.lonOverride = lat, lon
// Same "+ for West" → "+ for East" flip as the entity header.
out.latOverride, out.lonOverride = lat, -lon
out.hasLatLon = true
}
}
+16
View File
@@ -17,6 +17,22 @@ United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
=W1AW(5)[7],K,N,W,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK;
`
// cty.dat stores longitude "+ for West"; we normalise to the standard
// "+ for East" so bearing/distance math (and the cluster map) aren't mirrored.
// Regression guard for the Svalbard-azimuth bug (356° instead of 4°).
func TestLongitudeSign(t *testing.T) {
db, err := Load(strings.NewReader(sampleCty))
if err != nil {
t.Fatalf("load: %v", err)
}
if m, ok := db.Lookup("F4BPO"); !ok || m.Lon <= 0 { // France 2°E → +2
t.Errorf("France lon = %v, want positive (East)", m.Lon)
}
if m, ok := db.Lookup("K1ABC"); !ok || m.Lon >= 0 { // USA 92°W → ~-91.67
t.Errorf("USA lon = %v, want negative (West)", m.Lon)
}
}
func TestLookup(t *testing.T) {
db, err := Load(strings.NewReader(sampleCty))
if err != nil {
+21
View File
@@ -0,0 +1,21 @@
package lookup
import "testing"
func TestHomeCall(t *testing.T) {
cases := map[string]string{
"JW/OR1A": "OR1A", // portable prefix → home call
"DL/F4NIE": "F4NIE", // prefix/home
"F4BPO/P": "F4BPO", // suffix dropped
"F4BPO/9": "F4BPO", // call-area digit dropped
"VP8/F4BPO": "F4BPO", // both have digits → longest wins
"MM/KA9P": "KA9P", // Scotland prefix + US home
"OH2BH": "OH2BH", // no slash → unchanged
"3D2/RW3RN": "RW3RN",
}
for in, want := range cases {
if got := homeCall(in); got != want {
t.Errorf("homeCall(%q) = %q, want %q", in, got, want)
}
}
}
+49
View File
@@ -125,6 +125,32 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
// Portable / slashed call not found under its full form: the operator's
// record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look
// THAT up for the name/QTH/QSL info, then overwrite the location-determining
// fields with the SLASHED call's entity (JW = Svalbard, not OR1A's Belgium).
if home := homeCall(call); home != "" && home != call {
for _, p := range providers {
r, err := p.Lookup(ctx, home)
if err != nil {
continue
}
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
// The home record's location is the operator's HOME, not where they
// are portable now — clear it so cty.dat fills the real entity.
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
}
}
// All providers exhausted (not-found or errored). Try the cty.dat
// resolver as a last resort — at least we can hand back country/zones
// even for unknown callsigns. Not cached: a "cty.dat-only" result
@@ -149,6 +175,29 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
return Result{}, lastErr
}
// homeCall extracts the operator's home callsign from a slashed/portable call
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
// F4BPO. The home call is the "/"-part that looks like a real callsign (has a
// digit, ≥3 chars); the longest such part wins (handles PREFIX/HOMECALL where
// both could qualify, e.g. VP8/F4BPO). Returns "" if none qualifies.
func homeCall(call string) string {
if !strings.ContainsRune(call, '/') {
return call
}
best := ""
for _, p := range strings.Split(call, "/") {
p = strings.TrimSpace(p)
if len(p) < 3 || !strings.ContainsAny(p, "0123456789") {
continue // a prefix (JW, DL) or a suffix (P, M, MM, QRP, a digit)
}
if len(p) > len(best) {
best = p
}
}
return best
}
// normalizeNames title-cases the human-readable text fields so a QRZ/HamQTH
// reply in ALL CAPS ("NOEL CHENAVARD", "VETRAZ-MONTHOUX") is stored and shown
// consistently ("Noel Chenavard", "Vetraz-Monthoux"). State/zones/grid are