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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user