Let the published .udc files score the contests they are written for

I wrote SA 10 and OK/OM DX SSB in code after two failed attempts at the
download. The download works — the page posts a nonce with the form — and the
files say I got both contests wrong.

SA 10 is deleted from the code. Its file scores the station's log to the
contact, where the code had the wrong Cabrillo name and counted its multipliers
once in the contest rather than once per mode.

OK/OM DX now covers the CW running only, which is what N1MM's class is for and
what N1MM's own documentation says: the SSB running has different rules and a
pair of .udc files, one for each side. The CW rules go back to the class, so
Czechia and Slovakia are one side of the contest again. With the SSB files in
place both of the station's SSB logs score to the contact.

Reading those files turned up four things in the .udc reader: a file whose
extension is upper case was passed over, `Country` was not read as a
multiplier source, `OtherCountry` was not a points condition, and a section
multiplier counted the serial numbers the other side of a contest sends.

All Asian stays in code — N1MM has a class for it after all — with the Cabrillo
name and the band table it uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 10:00:21 +00:00
parent 264036f0f2
commit 38e9010802
13 changed files with 151 additions and 155 deletions

View File

@@ -18,7 +18,7 @@ public sealed class ContestRegistry
new("WAE", "Worked All Europe DX", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new Wae(m)),
new("ARRLRTTY", "ARRL RTTY Roundup", [ModeCategory.Digital], _ => new RttyRoundup()),
new("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)),
new("OKOMDX", "Czech and Slovak Republics DX", [ModeCategory.Cw, ModeCategory.Phone], m => new OkOmDx(m)),
new("OKOMDX", "Czech and Slovak Republics DX", [ModeCategory.Cw], _ => new OkOmDx()),
new("YOTA", "YOTA Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new Yota()),
new("IOTA", "RSGB Islands On The Air", [ModeCategory.Cw, ModeCategory.Phone], _ => new Iota()),
new("EUDXC", "EU DX Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new EuDxContest()),
@@ -35,7 +35,6 @@ public sealed class ContestRegistry
new("FDREG1", "IARU Region 1 Field Day", [ModeCategory.Cw, ModeCategory.Phone], _ => new FieldDayRegionOne()),
new("SARTGRTTY", "SARTG World Wide RTTY", [ModeCategory.Digital], _ => new SartgRtty()),
new("ALLASIA", "All Asian DX", [ModeCategory.Cw, ModeCategory.Phone], m => new AllAsian(m)),
new("SA10", "SA 10 Metre Contest", [ModeCategory.Cw, ModeCategory.Phone], _ => new SouthAmerica10()),
new("DX", "General logging", [], _ => new GeneralLogging()),
];
@@ -60,15 +59,18 @@ public sealed class ContestRegistry
return new ContestRegistry(byName);
}
/// Reads every `.udc` file in the folder. A file that will not parse is
/// reported rather than silently left out.
/// Reads every `.udc` file in the folder. Published files come with the
/// extension in either case, and a file that will not parse is reported
/// rather than silently left out.
public static ContestRegistry FromFolder(string folder, out IReadOnlyList<string> problems)
{
List<UdcFile> files = [];
List<string> failures = [];
if (Directory.Exists(folder))
{
foreach (string path in Directory.EnumerateFiles(folder, "*.udc"))
IEnumerable<string> found = Directory.EnumerateFiles(folder)
.Where(f => Path.GetExtension(f).Equals(".udc", StringComparison.OrdinalIgnoreCase));
foreach (string path in found)
{
try
{

View File

@@ -6,11 +6,7 @@ namespace Nonemm.Contests;
/// never plain `CQWW`. Our registry is keyed by the family, so a log written by
/// N1MM has to have its contest name turned back into a family and a mode
/// before the rules can be built.
///
/// The `.udc` files people use for OK/OM DX name it per mode as well, and one
/// file per side of the contest — `OKOMDXS_DX` is the one a station outside OK
/// and OM runs. Both sides are the same rules here, so both names resolve to
/// the same contest.
public static class N1mmContestNames
{
private static readonly Dictionary<string, (string Family, ModeCategory Mode)> Known =
@@ -32,10 +28,6 @@ public static class N1mmContestNames
["NAQPCW"] = ("NAQP", ModeCategory.Cw),
["NAQPSSB"] = ("NAQP", ModeCategory.Phone),
["NAQPRTTY"] = ("NAQP", ModeCategory.Digital),
["OKOMDXC"] = ("OKOMDX", ModeCategory.Cw),
["OKOMDXS"] = ("OKOMDX", ModeCategory.Phone),
["OKOMDXC_DX"] = ("OKOMDX", ModeCategory.Cw),
["OKOMDXS_DX"] = ("OKOMDX", ModeCategory.Phone),
["CQ160CW"] = ("CQ160", ModeCategory.Cw),
["CQ160SSB"] = ("CQ160", ModeCategory.Phone),
["EU_DXC"] = ("EUDXC", ModeCategory.Cw),
@@ -46,7 +38,6 @@ public static class N1mmContestNames
["BARTGSRTTY"] = ("BARTGRTTYS", ModeCategory.Digital),
["ALLASIACW"] = ("ALLASIA", ModeCategory.Cw),
["ALLASIASSB"] = ("ALLASIA", ModeCategory.Phone),
["SA10_DX"] = ("SA10", ModeCategory.Phone),
};
public static bool TryResolve(string name, out string family, out ModeCategory mode)

View File

@@ -4,7 +4,8 @@ namespace Nonemm.Contests.Rules;
/// The JARL All Asian DX contest. Only contacts with Asia count for a station
/// outside it. The exchange is the operator's age, the points go by band, and
/// each prefix worked counts once per band.
/// each prefix worked counts once per band. An Asian station scores three
/// times what a station outside Asia scores for the same contact.
public sealed class AllAsian : Contest
{
private readonly ModeCategory mode;
@@ -15,7 +16,7 @@ public sealed class AllAsian : Contest
public string DisplayName => $"All Asian DX {ModeLabel()}";
public string CabrilloName => mode == ModeCategory.Cw ? "AA-CW" : "AA-SSB";
public string CabrilloName => mode == ModeCategory.Cw ? "AADX-CW" : "AADX-SSB";
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
[
@@ -33,7 +34,14 @@ public sealed class AllAsian : Contest
public string SentExchangeFor(StationInfo me) => "";
public int PointsFor(QsoContext qso) => IsWorkable(qso) ? PointsOn(qso.Band) : 0;
public int PointsFor(QsoContext qso)
{
if (!IsWorkable(qso))
{
return 0;
}
return PointsOn(qso.Band) * (qso.Me.Continent == "AS" ? 3 : 1);
}
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso) =>
IsWorkable(qso) && qso.Qso.Call.WpxPrefix() is { } prefix
@@ -60,10 +68,11 @@ public sealed class AllAsian : Contest
private static bool IsWorkable(QsoContext qso) =>
qso.Continent == "AS" || qso.Me.Continent == "AS";
/// 160 metres is worth 3, 80 and 10 metres 2, and every other band 1.
private static int PointsOn(Band? band) => band?.Name switch
{
"160M" or "80M" => 3,
"40M" => 2,
"160M" => 3,
"80M" or "10M" => 2,
null => 0,
_ => 1,
};

View File

@@ -3,19 +3,19 @@ using Nonemm.Core.Country;
namespace Nonemm.Contests.Rules;
/// OK/OM DX. An OK or OM station sends a county code and everyone else a
/// OK/OM DX, CW. An OK or OM station sends a county code and everyone else a
/// serial number, and which side the operator is on decides what a contact
/// scores.
/// scores and what it counts for.
///
/// The SSB running of this contest has its own rules and its own pair of `.udc`
/// files, `OKOMDXS` and `OKOMDXS_DX`, one for each side. N1MM says not to use
/// this contest for it, and neither should we.
///
/// The received exchange lands in the section column for an OK or OM station
/// and in the exchange column for everyone else, which is where N1MM keeps
/// each of them.
public sealed class OkOmDx : Contest
{
private readonly ModeCategory mode;
public OkOmDx(ModeCategory mode) => this.mode = mode;
public string Name => "OKOMDX";
public string DisplayName => "Czech and Slovak Republics DX";
@@ -39,52 +39,52 @@ public sealed class OkOmDx : Contest
_ => false,
};
public IReadOnlyList<string> MultiplierNames => ["Countries", "Counties"];
public IReadOnlyList<string> MultiplierNames => ["Mults"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => true;
public IReadOnlyList<ModeCategory> Modes => [mode];
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Cw];
public string SentExchangeFor(StationInfo me) => IsOkOm(me.CountryPrefix) ? me.County : "001";
/// A maritime mobile is 5 points to either side. An OK or OM operator gets
/// 2 points at home, 3 elsewhere on the continent and 5 from another —
/// Czechia and Slovakia are two countries, so they are 3 points to each
/// other. Everyone else gets 10 points for an OK or OM station, 1 at home,
/// 3 on their own continent and 5 from another.
/// 2 points for another OK or OM station, 3 for the rest of Europe and 5
/// for another continent. Everyone else gets 10 points for an OK or OM
/// station, 1 at home, 3 on their own continent and 5 from another.
public int PointsFor(QsoContext qso)
{
if (qso.Qso.Call.IsMaritimeMobile)
{
return 5;
}
if (!IsOkOm(qso.Me.CountryPrefix) && IsOkOm(qso.CountryPrefix))
bool theirs = IsOkOm(qso.CountryPrefix);
if (IsOkOm(qso.Me.CountryPrefix))
{
return theirs ? 2 : qso.Continent == "EU" ? 3 : 5;
}
if (theirs)
{
return 10;
}
int home = IsOkOm(qso.Me.CountryPrefix) ? 2 : 1;
return qso.IsSameCountry ? home : qso.IsSameContinent ? 3 : 5;
return qso.IsSameCountry ? 1 : qso.IsSameContinent ? 3 : 5;
}
/// Both sides count the same two, once per band each: every country, and
/// every OK or OM county. N1MM's own class counts prefixes for an OK or OM
/// operator, which was the rule before the counties came in.
/// Once per band: an OK or OM operator counts the prefixes of the stations
/// it works, and everyone else counts the OK and OM counties.
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
string band = qso.Band?.Name ?? "";
List<Multiplier> found = [];
if (qso.CountryPrefix.Length > 0)
bool theirs = IsOkOm(qso.CountryPrefix);
if (IsOkOm(qso.Me.CountryPrefix))
{
found.Add(new Multiplier(1, qso.CountryPrefix, band));
return theirs || qso.Qso.Call.WpxPrefix() is not { } prefix
? []
: [new Multiplier(1, prefix, band)];
}
string county = County(qso.Qso);
if (IsOkOm(qso.CountryPrefix) && county.Length > 0)
{
found.Add(new Multiplier(2, county, band));
}
return found;
return theirs && county.Length > 0 ? [new Multiplier(1, county, band)] : [];
}
public int TotalScore(ScoreTally tally, ContestEntry entry) =>

View File

@@ -1,67 +0,0 @@
using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// The SA 10 metre contest. A South American station is worth 4 points and
/// everyone else 2. The exchange is a CQ zone, and both the prefixes and the
/// zones are multipliers. One band, so nothing is counted per band.
///
/// N1MM logs this one through a `.udc` file, so the Cabrillo name here is the
/// contest name and has not been checked against the sponsor's robot.
public sealed class SouthAmerica10 : Contest
{
public string Name => "SA10";
public string DisplayName => "SA 10 Metre Contest";
public string CabrilloName => "SA10";
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
[
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("Zone", ExchangeSlot.Section, ExchangeFieldKind.CqZone),
];
public IReadOnlyList<string> MultiplierNames => ["Prefixes", "Zones"];
public DupeScope DupeScope => DupeScope.PerMode;
public bool HasSerialNumbers => false;
public IReadOnlyList<ModeCategory> Modes => [ModeCategory.Cw, ModeCategory.Phone];
public string SentExchangeFor(StationInfo me) => me.CqZone.ToString();
public int PointsFor(QsoContext qso) => qso.Continent == "SA" ? 4 : 2;
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
List<Multiplier> found = [];
if (qso.Qso.Call.WpxPrefix() is { } prefix)
{
found.Add(new Multiplier(1, prefix, ""));
}
string zone = qso.Qso.Section.Trim();
if (zone.Length > 0)
{
found.Add(new Multiplier(2, zone, ""));
}
return found;
}
public int TotalScore(ScoreTally tally, ContestEntry entry) =>
tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
new CabrilloField(me.CqZone.ToString(), 3),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField(qso.Section, 3),
]);
}

View File

@@ -17,6 +17,7 @@ public static class UdcCondition
{
"" => false,
"MYCOUNTRY" => qso.IsSameCountry,
"OTHERCOUNTRY" => qso.CountryPrefix.Length > 0 && !qso.IsSameCountry,
"MYEXCHANGE" => Same(qso.Qso.Section, mySentExchange),
"MYGRID" => SameGridField(qso.Qso.GridSquare, qso.Me.GridSquare),
"MYCQZONE" => qso.CqZone > 0 && qso.CqZone == qso.Me.CqZone,

View File

@@ -86,13 +86,13 @@ public sealed class UdcMultiplier
private string? ValueFor(QsoContext qso) => source switch
{
"COUNTRYPREFIX" => qso.CountryPrefix,
"COUNTRYPREFIX" or "COUNTRY" => qso.CountryPrefix,
"EU_COUNTRY" or "AS_COUNTRY" or "NA_COUNTRY" or "SA_COUNTRY" or "AF_COUNTRY" or "OC_COUNTRY" =>
CountryOnContinent(qso, source[..2]),
"WPXPREFIX" => qso.Qso.Call.WpxPrefix(),
"2LPREFIX" => Truncate(qso.Qso.Call.Text, 2),
"LASTLETTER" => LastLetter(qso.Qso.Call.Text),
"SECTION" or "SECT" => Upper(qso.Qso.Section),
"SECTION" or "SECT" => SectionName(qso.Qso.Section),
"EXCHANGE" or "EXCH" => Upper(qso.Qso.Exchange1),
"MISC" or "MISCTEXT" => Upper(qso.Qso.MiscText),
"COMMENT" => Upper(qso.Qso.Comment),
@@ -127,6 +127,18 @@ public sealed class UdcMultiplier
private static string Upper(string text) => text.Trim().ToUpperInvariant();
/// A section multiplier ignores a value that is all digits. N1MM checks the
/// received exchange against the section list the contest names, and a file
/// whose section column also carries the serial numbers the other side of
/// the contest sends — OK/OM DX SSB is one — would otherwise count every
/// serial number as a multiplier. The section lists themselves are not held
/// here.
private static string? SectionName(string text)
{
string section = Upper(text);
return section.Length > 0 && section.All(char.IsAsciiDigit) ? null : section;
}
private static string? LastLetter(string call)
{
string trimmed = call.Trim().ToUpperInvariant();