diff --git a/app.go b/app.go index c3d500d..3c250d4 100644 --- a/app.go +++ b/app.go @@ -7237,6 +7237,19 @@ func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (looku } } a.enrichFromULS(&r, callsign) + // An entity that IS a single island group answers for itself. QRZ carries + // only for the operators who filled it in, and most have not; but a + // station in Ascension is on AF-003 whatever its callbook record says, and + // the entity is known for every callsign from cty.dat alone. + // + // So this reaches the operator with no QRZ subscription, and the station + // that has never touched a callbook — and it is right when the two + // disagree only in the sense that the callbook wins: filled ONLY when the + // callbook gave nothing, because an operator who typed their own reference + // knows something a table cannot (an IOTA-heavy entity, a rare island). + if r.IOTA == "" && r.DXCC != 0 { + r.IOTA = awardref.IOTAForDXCC(r.DXCC) + } // Custom outbound rows bound to a lookup. After the enrichment, so a // template sees the same grid and county the entry panel is about to show — // two answers for one callsign is how a rotator ends up pointed elsewhere diff --git a/changelog.json b/changelog.json index 7658c12..dc2db35 100644 --- a/changelog.json +++ b/changelog.json @@ -2,8 +2,12 @@ { "version": "0.25.8", "date": "", - "en": [], - "fr": [] + "en": [ + "An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed." + ], + "fr": [ + "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook." + ] }, { "version": "0.25.7", diff --git a/internal/awardref/dxcciota.go b/internal/awardref/dxcciota.go new file mode 100644 index 0000000..841faa4 --- /dev/null +++ b/internal/awardref/dxcciota.go @@ -0,0 +1,129 @@ +package awardref + +// One-IOTA DXCC entities: a table of the entities that ARE a single IOTA group. +// +// WHY IT EXISTS. QRZ.com carries for an operator who fills it in, and +// most do not. But for a great many entities the island reference follows from +// the entity alone — a station in Ascension Island is on AF-003, there is +// nothing else it could be — and the entity is known for every callsign, from +// cty.dat, without any callbook at all. So the reference can be filled for an +// operator with no QRZ subscription, on a station that has never touched a +// callbook, before the contact is logged. +// +// Only entities that map to EXACTLY ONE reference are here. France is not: a +// French station is usually on the mainland and on no island at all, and +// guessing would put a reference on hundreds of contacts that earn none. +// +// SOURCE: dxcc_matches_one_iota.json from the IOTA programme +// (www.iota-world.org/islands-on-the-air/downloads/), fetched 2026-08-17, +// 99 entities. It changes only when an entity appears or IOTA re-maps one, so +// it is a table here rather than a download: it then works offline, which is +// where a portable station usually is. To refresh, fetch that file again and +// re-emit this map, sorted by entity number. +// +// Entity names are comments only, joined from internal/dxcc for readability. +var iotaByDXCC = map[int]string{ + 5: "EU-002", // Aland Islands + 10: "AF-002", // Amsterdam & St. Paul Is. + 12: "NA-022", // Anguilla + 17: "NA-020", // Aves Island + 20: "OC-089", // Baker & Howland Islands + 21: "EU-004", // Balearic Islands + 24: "AN-002", // Bouvet + 29: "AF-004", // Canary Islands + 34: "OC-038", // Chatham Islands + 35: "OC-002", // Christmas Island + 36: "NA-011", // Clipperton Island + 37: "NA-012", // Cocos Island + 38: "OC-003", // Cocos (Keeling) Islands + 41: "AF-008", // Crozet Island + 43: "NA-095", // Desecheo Island + 45: "EU-001", // Dodecanese + 62: "NA-021", // Barbados + 64: "NA-005", // Bermuda + 65: "NA-023", // British Virgin Islands + 69: "NA-016", // Cayman Islands + 71: "SA-004", // Galapagos Islands + 82: "NA-097", // Jamaica + 84: "NA-107", // Martinique + 91: "SA-036", // Aruba + 94: "NA-100", // Antigua & Barbuda + 95: "NA-101", // Dominica + 96: "NA-103", // Montserrat + 97: "NA-108", // St. Lucia + 99: "AF-011", // Glorioso Islands + 103: "OC-026", // Guam + 105: "NA-015", // Guantanamo Bay + 106: "EU-114", // Guernsey + 111: "AN-003", // Heard Island + 114: "EU-116", // Isle Of Man + 118: "EU-022", // Jan Mayen + 123: "OC-023", // Johnston Island + 131: "AF-048", // Kerguelen Islands + 133: "OC-039", // Kermadec Islands + 138: "OC-020", // Kure Island + 141: "SA-002", // Falkland Islands + 147: "OC-004", // Lord Howe Island + 153: "AN-005", // Macquarie Island + 157: "OC-031", // Nauru + 159: "AS-013", // Maldives + 161: "SA-007", // Malpelo Island + 165: "AF-049", // Mauritius + 166: "OC-086", // Mariana Islands + 167: "EU-053", // Market Reef + 169: "AF-027", // Mayotte + 171: "OC-072", // Mellish Reef + 174: "OC-030", // Midway Island + 177: "OC-073", // Minami Torishima + 182: "NA-098", // Navassa Island + 188: "OC-040", // Niue + 189: "OC-005", // Norfolk Island + 190: "OC-097", // Samoa + 195: "AF-039", // Annobon Island + 199: "AN-004", // Peter 1 Island + 201: "AF-021", // Pr. Edward & Marion Is. + 205: "AF-003", // Ascension Island + 207: "AF-017", // Rodriguez Island + 211: "NA-063", // Sable Island + 217: "SA-013", // San Felix & San Ambrosio + 222: "EU-018", // Faroe Islands + 238: "AN-008", // South Orkney Islands + 240: "AN-009", // South Sandwich Islands + 241: "AN-010", // South Shetland Islands + 247: "AS-051", // Spratly Islands + 249: "NA-104", // St. Kitts & Nevis + 250: "AF-022", // St. Helena + 252: "NA-094", // St. Paul Island + 253: "SA-014", // St. Peter & St. Paul + 257: "EU-023", // Malta + 270: "OC-048", // Tokelau Islands + 273: "SA-010", // Trindade & Martim Vaz + 276: "AF-031", // Tromelin Island + 277: "NA-032", // St. Pierre & Miquelon + 282: "OC-015", // Tuvalu + 283: "AS-004", // Uk Base Areas On Cyprus + 285: "NA-106", // Us Virgin Islands + 297: "OC-053", // Wake Island + 301: "OC-017", // Western Kiribati + 303: "OC-007", // Willis Island + 381: "AS-019", // Singapore + 411: "AF-007", // Comoros + 453: "AF-016", // Reunion Island + 460: "OC-060", // Rotuma Island + 489: "OC-112", // Conway Reef + 490: "OC-018", // Banaba Island + 505: "AS-110", // Pratas Island + 506: "AS-116", // Scarborough Reef + 509: "OC-027", // Marquesas Islands + 512: "OC-176", // Chesterfield Islands + 513: "OC-182", // Ducie Island + 515: "OC-200", // Swains Island + 516: "NA-146", // St. Barthelemy + 517: "SA-099", // Curacao + 519: "NA-145", // Saba & St. Eustatius + 520: "SA-006", // Bonaire +} + +// IOTAForDXCC returns the island reference of a DXCC entity that is a single +// IOTA group, or "" for an entity that holds several islands or none. +func IOTAForDXCC(dxcc int) string { return iotaByDXCC[dxcc] } diff --git a/internal/awardref/dxcciota_test.go b/internal/awardref/dxcciota_test.go new file mode 100644 index 0000000..7a1e4c5 --- /dev/null +++ b/internal/awardref/dxcciota_test.go @@ -0,0 +1,51 @@ +package awardref + +import ( + "regexp" + "testing" +) + +// The entities an operator meets: an island group that is its own DXCC, and a +// mainland country that is not. +func TestIOTAForDXCC(t *testing.T) { + cases := []struct { + dxcc int + want string + why string + }{ + {205, "AF-003", "Ascension Island is one island and one reference"}, + {5, "EU-002", "Aland Islands"}, + {12, "NA-022", "Anguilla"}, + {24, "AN-002", "Bouvet — the entity whose entry is checked most often and worked least"}, + // France holds hundreds of islands and, far more to the point, a + // mainland. Filling a reference here would earn nothing and would put + // one on nearly every European contact in the log. + {227, "", "France is not one IOTA"}, + {291, "", "the United States is not one IOTA"}, + {0, "", "no entity resolved"}, + {99999, "", "not an entity at all"}, + } + for _, c := range cases { + if got := IOTAForDXCC(c.dxcc); got != c.want { + t.Errorf("IOTAForDXCC(%d) = %q, want %q — %s", c.dxcc, got, c.want, c.why) + } + } +} + +// Every reference in the table must be a well-formed IOTA reference, because +// one that is not would be written onto a contact's award references and count +// for nothing — and would then have to be found by hand, one QSO at a time. +func TestEveryOneIOTAEntryIsWellFormed(t *testing.T) { + ref := regexp.MustCompile(`^(AF|AN|AS|EU|NA|OC|SA)-\d{3}$`) + if len(iotaByDXCC) < 50 { + t.Fatalf("the table holds %d entities — it has been truncated", len(iotaByDXCC)) + } + for dxcc, r := range iotaByDXCC { + if dxcc <= 0 { + t.Errorf("entity number %d is not a DXCC entity", dxcc) + } + if !ref.MatchString(r) { + t.Errorf("entity %d maps to %q, which is not an IOTA reference", dxcc, r) + } + } +}