Score Worked All Europe, QTC traffic and all

WAE is two contests in one. The QSOs are scored like any other, except that a
multiplier is worth four on 80 metres, three on 40 and two on the high bands, so
Multiplier grew a weight and ScoreTally sums weights rather than counting.
Europe works the rest of the world and the rest of the world works Europe; on
RTTY that rule is dropped and everybody works everybody.

The other half is QTC traffic: one station reads back contacts it has already
made, ten to a series, and every line is a point for both stations. Those lines
live in the log as rows of their own, exactly where N1MM puts them — Exchange1
says SQTC or RQTC, the reported contact's time, call and number go in the sent
report, received report and sent number columns, and the series in misc text.
So a log written by either program opens in the other with its traffic intact.
A QTC row is not a worked station: Contest.IsContact says so, and ContestLog
keeps such rows out of the dupe index and the multiplier index while still
counting their points. Cabrillo writes them as QTC: records.

Checked against eight real WAE logs rather than against my own reading of the
rules. Points agree with N1MM on all eight, to the contact. Weighted
multipliers agree on the six logs from 2022 on; the two older ones differ only
where the country file has since changed its mind, and in one place on purpose:
N1MM counts a callsign it cannot place as a multiplier with an empty value.

That comparison turned up two bugs of ours. A KG4 call is Guantanamo Bay only
when it is three or five characters long — KG4NE is, KG4IGC is an ordinary US
call — and the country file cannot say so, so every program that reads it
carries the rule. And Callsign is a record holding a list of modifiers, so the
generated equality compared that list by reference and two parses of one call
came out different.

Entering QTCs while operating is not there yet; the window is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:48:47 +00:00
parent 86c01f246f
commit 8961677354
19 changed files with 533 additions and 13 deletions

View File

@@ -34,6 +34,11 @@ public interface Contest
int PointsFor(QsoContext qso);
/// False for a row in the log that is not a worked station. WAE keeps its
/// QTC traffic in the log, and those rows score but are not contacts: they
/// bring no multiplier and cannot be a dupe.
bool IsContact(Qso qso) => true;
IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso);
int TotalScore(ScoreTally tally);

View File

@@ -34,6 +34,10 @@ public sealed class ContestLog
public Verdict Judge(Qso candidate)
{
QsoContext context = ContextFor(candidate);
if (!contest.IsContact(candidate))
{
return new Verdict(false, contest.PointsFor(context), []);
}
if (contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate)))
{
return Verdict.Dupe;
@@ -85,7 +89,9 @@ public sealed class ContestLog
}
public bool IsWorked(Qso candidate) =>
contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate));
contest.DupeScope != DupeScope.Never
&& contest.IsContact(candidate)
&& workedKeys.Contains(DupeKey(candidate));
/// Every contact with this call, newest first.
public IReadOnlyList<Qso> WorkedBefore(string call) =>
@@ -106,12 +112,16 @@ public sealed class ContestLog
private void Index(Qso qso, Verdict verdict)
{
workedKeys.Add(DupeKey(qso));
tally.AddQso(verdict.Points);
if (!contest.IsContact(qso))
{
return;
}
workedKeys.Add(DupeKey(qso));
foreach (Multiplier multiplier in verdict.NewMultipliers)
{
claimedMultipliers.Add(MultiplierKey(multiplier));
tally.AddMultiplier(multiplier.Index);
tally.AddMultiplier(multiplier);
}
}

View File

@@ -15,6 +15,7 @@ public sealed class ContestRegistry
new("ARRLDX", "ARRL International DX", [ModeCategory.Cw, ModeCategory.Phone], m => new ArrlDx(m)),
new("IARU", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()),
new("SS", "ARRL Sweepstakes", [ModeCategory.Cw, ModeCategory.Phone], m => new Sweepstakes(m)),
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("DX", "General logging", [], _ => new GeneralLogging()),

View File

@@ -2,7 +2,11 @@ namespace Nonemm.Contests;
/// One multiplier a QSO claims. `Scope` is what makes it distinct: a country
/// counted once per band carries the band, one counted once carries nothing.
public sealed record Multiplier(int Index, string Value, string Scope)
///
/// `Weight` is how much this one is worth in the total. WAE counts a
/// multiplier four times on 80 metres and twice on 20; everywhere else a
/// multiplier is a multiplier and the weight is 1.
public sealed record Multiplier(int Index, string Value, string Scope, int Weight = 1)
{
public override string ToString() => Scope.Length == 0 ? Value : $"{Value}/{Scope}";
}

View File

@@ -17,6 +17,9 @@ public static class N1mmContestNames
["CQWPXCW"] = ("CQWPX", ModeCategory.Cw),
["CQWPXSSB"] = ("CQWPX", ModeCategory.Phone),
["CQWPXRTTY"] = ("CQWPX", ModeCategory.Digital),
["WAECW"] = ("WAE", ModeCategory.Cw),
["WAESSB"] = ("WAE", ModeCategory.Phone),
["WAERTTY"] = ("WAE", ModeCategory.Digital),
["ARRLDXCW"] = ("ARRLDX", ModeCategory.Cw),
["ARRLDXSSB"] = ("ARRLDX", ModeCategory.Phone),
["SSCW"] = ("SS", ModeCategory.Cw),

View File

@@ -0,0 +1,189 @@
using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// Worked All Europe DX, CW, SSB and RTTY. Europe works the rest of the world
/// and the rest of the world works Europe; on RTTY everybody works everybody.
///
/// Two things make it unlike the other contests here. A multiplier is worth
/// more on the low bands — four times on 80 metres, three on 40, twice on 20,
/// 15 and 10 — and the contest is half about QTC traffic: a station reports
/// contacts it has already made to the station it is working, and each line
/// reported is a point for both of them. Those lines live in the log as rows of
/// their own; see `WaeQtc`.
public sealed class Wae : Contest
{
/// The countries whose call areas count separately, so W1 and W2 are two
/// multipliers rather than one.
private static readonly IReadOnlySet<string> CallAreaCountries =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"K", "VE", "VK", "ZL", "ZS", "JA", "PY", "BY",
};
/// Asiatic Russia is split by call area too, and the multiplier is written
/// `UA` and the call area digit whatever the prefix on the air, so RM0W and
/// UA0AB are one multiplier.
private static readonly IReadOnlySet<string> AsiaticRussia =
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "UA8", "UA9", "UA0" };
private readonly ModeCategory mode;
public Wae(ModeCategory mode) => this.mode = mode;
public string Name => mode switch
{
ModeCategory.Phone => "WAESSB",
ModeCategory.Digital => "WAERTTY",
_ => "WAECW",
};
public string DisplayName => $"Worked All Europe DX {ModeLabel()}";
public string CabrilloName => mode switch
{
ModeCategory.Phone => "WAE-SSB",
ModeCategory.Digital => "WAE-RTTY",
_ => "WAE-CW",
};
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) =>
[
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
];
public IReadOnlyList<string> MultiplierNames => ["Mults"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => true;
public IReadOnlyList<ModeCategory> Modes => [mode];
public string SentExchangeFor(StationInfo me) => "001";
/// A QTC line counts, and so does every contact on a contest band. The
/// WARC bands are not in this contest, so a contact there is worth nothing.
public int PointsFor(QsoContext qso)
{
if (!IsWorkable(qso))
{
return 0;
}
return WaeQtc.IsQtc(qso.Qso) || WeightFor(qso.Band) > 0 ? 1 : 0;
}
public bool IsContact(Qso qso) => !WaeQtc.IsQtc(qso);
/// One multiplier per band, worth more the lower the band. For a station in
/// Europe it is the other station's country or call area; for a station
/// outside it, the European country worked.
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
int weight = WeightFor(qso.Band);
if (weight == 0 || !IsWorkable(qso) || !qso.Qso.Call.CountsForEntity)
{
return [];
}
string value = MultiplierValue(qso);
return value.Length == 0
? []
: [new Multiplier(1, value, qso.Band?.Name ?? "", weight)];
}
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
/// A QTC is written as its own Cabrillo record: the station that received
/// the traffic, the series, the station that sent it, and then the contact
/// being reported.
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me)
{
if (WaeQtc.From(qso) is not { } qtc)
{
return new CabrilloExchange(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
new CabrilloField($"{qso.SentNumber:0000}", 4),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField($"{qso.ReceivedNumber:0000}", 4),
]);
}
string receiver = qtc.IsSent ? qtc.Station.Text : me.Callsign;
string sender = qtc.IsSent ? me.Callsign : qtc.Station.Text;
return new CabrilloExchange(
[
new CabrilloField(receiver, 13),
new CabrilloField(qtc.SeriesText, 10),
new CabrilloField(sender, 13),
],
[
new CabrilloField(qtc.TimeUtc, 4),
new CabrilloField(qtc.Call.Text, 13),
new CabrilloField($"{qtc.Number:0000}", 4),
]);
}
/// On CW and SSB the contest is Europe against the rest of the world, so a
/// contact with your own side of that line is worth nothing. RTTY drops the
/// rule and everybody works everybody.
public bool IsWorkable(QsoContext qso)
{
if (mode == ModeCategory.Digital)
{
return true;
}
return qso.Me.Continent == "EU" ? qso.Continent != "EU" : qso.Continent == "EU";
}
/// 80 metres is worth four, 40 three, and 20, 15 and 10 two. Everything
/// else is not in the contest.
public static int WeightFor(Band? band) => band?.Name switch
{
"80M" => 4,
"40M" => 3,
"20M" or "15M" or "10M" => 2,
_ => 0,
};
private string MultiplierValue(QsoContext qso)
{
string prefix = qso.CountryPrefix;
if (prefix.Length == 0)
{
return "";
}
// outside Europe on CW and SSB, only European countries are worked and
// the country alone is the multiplier
if (mode != ModeCategory.Digital && qso.Me.Continent != "EU")
{
return prefix;
}
if (AsiaticRussia.Contains(prefix))
{
return $"UA{CallAreaDigit(qso) ?? '9'}";
}
return CallAreaCountries.Contains(prefix) ? CallArea(qso) : prefix;
}
/// The country prefix with the call area digit on the end: W1AW is `K1`.
private static string CallArea(QsoContext qso) =>
CallAreaDigit(qso) is { } digit ? $"{qso.CountryPrefix}{digit}" : qso.CountryPrefix;
private static char? CallAreaDigit(QsoContext qso)
{
string? wpx = qso.Qso.Call.WpxPrefix();
return wpx is { Length: > 0 } && char.IsAsciiDigit(wpx[^1]) ? wpx[^1] : null;
}
private string ModeLabel() => mode switch
{
ModeCategory.Cw => "CW",
ModeCategory.Phone => "SSB",
_ => "RTTY",
};
}

View File

@@ -0,0 +1,77 @@
using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// One line of WAE QTC traffic: a contact one station reports to the other.
/// Ten of them make a series, and the series is written `3/10` — the third
/// series, ten lines in it.
///
/// A QTC lives in the log as an ordinary row with `Exchange1` set to `SQTC` or
/// `RQTC`, which is where N1MM keeps it: the time goes in the sent report
/// column, the callsign in the received report column, and the serial number in
/// the sent number column. Reading it back out is `From`.
public sealed record WaeQtc(
Callsign Station,
bool IsSent,
int Series,
int CountInSeries,
string TimeUtc,
Callsign Call,
int Number)
{
public const string Contact = "QSO";
public const string Sent = "SQTC";
public const string Received = "RQTC";
/// Ten lines to a series, and ten to any one station for the whole contest.
public const int MostPerSeries = 10;
public const int MostPerStation = 10;
public string SeriesText => $"{Series}/{CountInSeries}";
public static bool IsQtc(Qso qso) =>
qso.Exchange1.Equals(Sent, StringComparison.OrdinalIgnoreCase)
|| qso.Exchange1.Equals(Received, StringComparison.OrdinalIgnoreCase);
/// Null for a row that is an ordinary contact.
public static WaeQtc? From(Qso qso)
{
if (!IsQtc(qso))
{
return null;
}
(int series, int count) = ParseSeries(qso.MiscText);
return new WaeQtc(
qso.Call,
qso.Exchange1.Equals(Sent, StringComparison.OrdinalIgnoreCase),
series,
count,
qso.SentReport,
Callsign.Parse(qso.ReceivedReport),
qso.SentNumber);
}
/// Fills this QTC into a log row. The caller supplies the row so the
/// frequency, mode and time of the traffic come from wherever it happened.
public Qso ApplyTo(Qso qso) => qso with
{
Call = Station,
Exchange1 = IsSent ? Sent : Received,
MiscText = SeriesText,
SentReport = TimeUtc,
ReceivedReport = Call.Text,
SentNumber = Number,
ReceivedNumber = 0,
};
private static (int Series, int Count) ParseSeries(string text)
{
string[] parts = text.Split('/', StringSplitOptions.TrimEntries);
return parts.Length == 2
&& int.TryParse(parts[0], out int series)
&& int.TryParse(parts[1], out int count)
? (series, count)
: (0, 0);
}
}

View File

@@ -9,6 +9,8 @@ public sealed class ScoreTally
public int Points { get; private set; }
/// What the multipliers of this type are worth, which is how many there
/// are unless the contest weights them.
public int MultiplierCount(int index) =>
multipliers.TryGetValue(index, out int count) ? count : 0;
@@ -26,9 +28,9 @@ public sealed class ScoreTally
Points -= points;
}
public void AddMultiplier(int index) =>
multipliers[index] = MultiplierCount(index) + 1;
public void AddMultiplier(Multiplier multiplier) =>
multipliers[multiplier.Index] = MultiplierCount(multiplier.Index) + multiplier.Weight;
public void RemoveMultiplier(int index) =>
multipliers[index] = MultiplierCount(index) - 1;
public void RemoveMultiplier(Multiplier multiplier) =>
multipliers[multiplier.Index] = MultiplierCount(multiplier.Index) - multiplier.Weight;
}