From 986f359a408245eb8a92900fc498ceab2f0437d8 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 24 Oct 2026 05:50:20 +0000 Subject: [PATCH] Read the zone lists in the country file The # blocks in wl_cty.dat list CQ and ITU zones by call area and the first letter after the digit, written with the main prefix letters. A call now takes them whatever its own prefix letters are, so RA9Y and RM9I get what UA9Y gets, as in N1MM. A call whose own prefix rule gives a zone, and a whole-call entry, are left as they are. Co-Authored-By: Claude Sonnet 5.5 --- src/Nonemm.Core/Country/CountryFile.cs | 97 ++++++++++++++++++- src/Nonemm.Core/Country/PrefixRule.cs | 6 ++ .../Country/CountryFileTests.cs | 33 +++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/Nonemm.Core/Country/CountryFile.cs b/src/Nonemm.Core/Country/CountryFile.cs index 025d04a..d11aa8c 100644 --- a/src/Nonemm.Core/Country/CountryFile.cs +++ b/src/Nonemm.Core/Country/CountryFile.cs @@ -1,25 +1,29 @@ using System.Globalization; +using System.Text.RegularExpressions; namespace Nonemm.Core.Country; /// The country file (`cty.dat` or `wl_cty.dat`), which says which entity, zone /// and continent a callsign belongs to. -public sealed class CountryFile +public sealed partial class CountryFile { private readonly Dictionary exactCalls; private readonly Dictionary prefixes; + private readonly Dictionary> zoneRules; private readonly int longestPrefix; private CountryFile( IReadOnlyList entities, Dictionary exactCalls, Dictionary prefixes, + Dictionary> zoneRules, string version) { Entities = entities; Version = version; this.exactCalls = exactCalls; this.prefixes = prefixes; + this.zoneRules = zoneRules; longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length); } @@ -38,16 +42,37 @@ public sealed class CountryFile List entities = []; Dictionary exactCalls = new(StringComparer.Ordinal); Dictionary prefixes = new(StringComparer.Ordinal); + Dictionary> zoneRules = []; DxccEntity? current = null; string version = ""; + bool isInZoneBlock = false; foreach (string rawLine in text.Split('\n')) { string line = rawLine.TrimEnd('\r', ' ', '\t'); - if (line.Length == 0 || line.StartsWith('#')) + if (line.Length == 0) { continue; } + if (line.StartsWith('#')) + { + // `# UA9: UA9H(18)[31],...;` is a comment to a program that + // ignores it and a list of zones by call area to one that + // reads it. It can run on over the lines that follow. + if (current is not null && ZoneBlockStart.Match(line) is { Success: true } start) + { + AddZoneRules(zoneRules, current, start.Groups[1].Value); + isInZoneBlock = !line.EndsWith(';'); + } + continue; + } + if (isInZoneBlock && char.IsWhiteSpace(rawLine[0]) && current is not null) + { + // the lines the block runs over are also read as aliases below, + // which is what makes UA9X a European Russia prefix + AddZoneRules(zoneRules, current, line); + isInZoneBlock = !line.EndsWith(';'); + } if (!char.IsWhiteSpace(rawLine[0])) { current = ParseHeader(line); @@ -75,7 +100,7 @@ public sealed class CountryFile { throw new FormatException("country file holds no entities"); } - return new CountryFile(entities, exactCalls, prefixes, version); + return new CountryFile(entities, exactCalls, prefixes, zoneRules, version); } public CountryLookup? Find(Callsign call) @@ -94,9 +119,73 @@ public sealed class CountryFile } return IsGuantanamoException(call) ? FindByPrefix("K") - : FindByPrefix(call.EntityLookupText()); + : FindByPrefix(call.EntityLookupText()) is { } found + ? WithZoneByCallArea(found, call.EntityLookupText()) + : null; } + /// The zone block of the file (`# UA9: UA9H(18)[31],...`) lists zones by + /// call area and the first letter after the digit, written with the + /// prefix letters of the entity's main prefix. N1MM reads it for every + /// call of the entity, whatever its own prefix letters are, so RA9Y and + /// RM9I get what UA9Y gets. A call whose own prefix rule already gives a + /// zone, such as VO2, is left as it is. + private CountryLookup WithZoneByCallArea(CountryLookup found, string text) + { + if (!zoneRules.TryGetValue(found.Entity, out List? rules) + || found.CqZone != found.Entity.CqZone + || found.ItuZone != found.Entity.ItuZone) + { + return found; + } + int digit = text.IndexOfAny(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); + if (digit < 1) + { + return found; + } + string tail = text[digit..]; + foreach (PrefixRule rule in rules) + { + int rulesDigit = rule.Key.IndexOfAny(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); + if (rulesDigit >= 1 && (rule.Key[..rulesDigit] + tail).StartsWith(rule.Key, StringComparison.Ordinal)) + { + return found with + { + CqZone = rule.CqZoneOverride ?? found.CqZone, + ItuZone = rule.ItuZoneOverride ?? found.ItuZone, + }; + } + } + return found; + } + + private static void AddZoneRules( + Dictionary> zoneRules, + DxccEntity entity, + string list) + { + if (!zoneRules.TryGetValue(entity, out List? rules)) + { + rules = []; + zoneRules[entity] = rules; + } + foreach (string item in list.TrimEnd(';').Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + PrefixRule rule = PrefixRule.Parse(entity, item.Trim()); + if (rule.Key.Length > 0) + { + rules.Add(rule); + } + } + // the longest prefix first, so VE3 is read before a shorter VE + rules.Sort((a, b) => b.Key.Length.CompareTo(a.Key.Length)); + } + + [GeneratedRegex(@"^#\s+\w+:\s*(.*)$")] + private static partial Regex ZoneBlockStartPattern(); + + private static readonly Regex ZoneBlockStart = ZoneBlockStartPattern(); + /// The country file lists `KG4` as Guantanamo Bay, but only a three or five /// character call is Guantanamo: KG4NE is, KG4IGC is an ordinary US call. /// The file cannot say that, so every program that reads it carries the diff --git a/src/Nonemm.Core/Country/PrefixRule.cs b/src/Nonemm.Core/Country/PrefixRule.cs index 7c8596d..df5014c 100644 --- a/src/Nonemm.Core/Country/PrefixRule.cs +++ b/src/Nonemm.Core/Country/PrefixRule.cs @@ -33,6 +33,12 @@ public sealed class PrefixRule public string Key { get; } + /// The CQ zone the alias gives, or null when it leaves the entity's own. + public int? CqZoneOverride => cqZone; + + /// The ITU zone the alias gives, or null when it leaves the entity's own. + public int? ItuZoneOverride => ituZone; + public static PrefixRule Parse(DxccEntity entity, string alias) { string key = alias; diff --git a/tests/Nonemm.Core.Tests/Country/CountryFileTests.cs b/tests/Nonemm.Core.Tests/Country/CountryFileTests.cs index 5270ec1..edeccbc 100644 --- a/tests/Nonemm.Core.Tests/Country/CountryFileTests.cs +++ b/tests/Nonemm.Core.Tests/Country/CountryFileTests.cs @@ -80,4 +80,37 @@ public class CountryFileTests [InlineData("404 not found")] public void TextThatIsNotACountryFileIsRejected(string text) => Assert.Throws(() => CountryFile.Parse(text)); + + private const string Russia = """ + Asiatic Russia: 17: 30: AS: 55.88: -84.08: -7.0: UA9: + # UA9: UA9H(18)[31],UA9J[20], + UA9Y(18)[31]; + # UA0: UA0F(19)[34],UA0Z(19)[35]; + R9,RA9,RM9,RZ9,RC0,UA9,UA0; + """; + + /// The file lists zones by call area and the first letter after the digit + /// under UA, and N1MM applies them to every call of the entity. + [Theory] + [InlineData("UA9YA", 18, 31)] + [InlineData("RA9Y", 18, 31)] + [InlineData("RZ9YI", 18, 31)] + [InlineData("RM9J", 17, 20)] + [InlineData("RC0F", 19, 34)] + [InlineData("RA9A", 17, 30)] + public void TheZoneComesFromTheCallAreaListInTheFile(string call, int cq, int itu) + { + CountryLookup? found = CountryFile.Parse(Russia).Find(call); + + Assert.Equal(cq, found?.CqZone); + Assert.Equal(itu, found?.ItuZone); + } + + [Fact] + public void ACallWhoseOwnPrefixGivesAZoneIsNotChangedByTheList() + { + CountryFile file = CountryFile.Parse(Russia.Replace("RC0", "RC0(23)[40]")); + + Assert.Equal(23, file.Find("RC0F")?.CqZone); + } }