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 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 05:50:20 +00:00
co-authored by Claude Sonnet 5.5
parent 3a39b40edb
commit 986f359a40
3 changed files with 132 additions and 4 deletions
+93 -4
View File
@@ -1,25 +1,29 @@
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions;
namespace Nonemm.Core.Country; namespace Nonemm.Core.Country;
/// The country file (`cty.dat` or `wl_cty.dat`), which says which entity, zone /// The country file (`cty.dat` or `wl_cty.dat`), which says which entity, zone
/// and continent a callsign belongs to. /// and continent a callsign belongs to.
public sealed class CountryFile public sealed partial class CountryFile
{ {
private readonly Dictionary<string, PrefixRule> exactCalls; private readonly Dictionary<string, PrefixRule> exactCalls;
private readonly Dictionary<string, PrefixRule> prefixes; private readonly Dictionary<string, PrefixRule> prefixes;
private readonly Dictionary<DxccEntity, List<PrefixRule>> zoneRules;
private readonly int longestPrefix; private readonly int longestPrefix;
private CountryFile( private CountryFile(
IReadOnlyList<DxccEntity> entities, IReadOnlyList<DxccEntity> entities,
Dictionary<string, PrefixRule> exactCalls, Dictionary<string, PrefixRule> exactCalls,
Dictionary<string, PrefixRule> prefixes, Dictionary<string, PrefixRule> prefixes,
Dictionary<DxccEntity, List<PrefixRule>> zoneRules,
string version) string version)
{ {
Entities = entities; Entities = entities;
Version = version; Version = version;
this.exactCalls = exactCalls; this.exactCalls = exactCalls;
this.prefixes = prefixes; this.prefixes = prefixes;
this.zoneRules = zoneRules;
longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length); longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length);
} }
@@ -38,16 +42,37 @@ public sealed class CountryFile
List<DxccEntity> entities = []; List<DxccEntity> entities = [];
Dictionary<string, PrefixRule> exactCalls = new(StringComparer.Ordinal); Dictionary<string, PrefixRule> exactCalls = new(StringComparer.Ordinal);
Dictionary<string, PrefixRule> prefixes = new(StringComparer.Ordinal); Dictionary<string, PrefixRule> prefixes = new(StringComparer.Ordinal);
Dictionary<DxccEntity, List<PrefixRule>> zoneRules = [];
DxccEntity? current = null; DxccEntity? current = null;
string version = ""; string version = "";
bool isInZoneBlock = false;
foreach (string rawLine in text.Split('\n')) foreach (string rawLine in text.Split('\n'))
{ {
string line = rawLine.TrimEnd('\r', ' ', '\t'); string line = rawLine.TrimEnd('\r', ' ', '\t');
if (line.Length == 0 || line.StartsWith('#')) if (line.Length == 0)
{ {
continue; 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])) if (!char.IsWhiteSpace(rawLine[0]))
{ {
current = ParseHeader(line); current = ParseHeader(line);
@@ -75,7 +100,7 @@ public sealed class CountryFile
{ {
throw new FormatException("country file holds no entities"); 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) public CountryLookup? Find(Callsign call)
@@ -94,9 +119,73 @@ public sealed class CountryFile
} }
return IsGuantanamoException(call) return IsGuantanamoException(call)
? FindByPrefix("K") ? 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<PrefixRule>? 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<DxccEntity, List<PrefixRule>> zoneRules,
DxccEntity entity,
string list)
{
if (!zoneRules.TryGetValue(entity, out List<PrefixRule>? 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 /// 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. /// 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 /// The file cannot say that, so every program that reads it carries the
+6
View File
@@ -33,6 +33,12 @@ public sealed class PrefixRule
public string Key { get; } 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) public static PrefixRule Parse(DxccEntity entity, string alias)
{ {
string key = alias; string key = alias;
@@ -80,4 +80,37 @@ public class CountryFileTests
[InlineData("<html>404 not found</html>")] [InlineData("<html>404 not found</html>")]
public void TextThatIsNotACountryFileIsRejected(string text) => public void TextThatIsNotACountryFileIsRejected(string text) =>
Assert.Throws<FormatException>(() => CountryFile.Parse(text)); Assert.Throws<FormatException>(() => 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);
}
} }