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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
189
src/Nonemm.Contests/Rules/Wae.cs
Normal file
189
src/Nonemm.Contests/Rules/Wae.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
77
src/Nonemm.Contests/Rules/WaeQtc.cs
Normal file
77
src/Nonemm.Contests/Rules/WaeQtc.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ public sealed record Callsign
|
||||
|
||||
public bool IsMaritimeMobile => Modifiers.Contains("MM");
|
||||
|
||||
/// Two callsigns are the same when they read the same. The generated record
|
||||
/// equality compared `Modifiers` by reference, so two parses of one call
|
||||
/// came out different.
|
||||
public bool Equals(Callsign? other) => other is not null && Text == other.Text;
|
||||
|
||||
public override int GetHashCode() => Text.GetHashCode(StringComparison.Ordinal);
|
||||
|
||||
public bool IsAeronauticalMobile => Modifiers.Contains("AM");
|
||||
|
||||
/// Maritime and aeronautical mobile stations count for no country and no
|
||||
|
||||
@@ -76,7 +76,21 @@ public sealed class CountryFile
|
||||
{
|
||||
return station.Resolve(call.Station);
|
||||
}
|
||||
return FindByPrefix(call.EntityLookupText());
|
||||
return IsGuantanamoException(call)
|
||||
? FindByPrefix("K")
|
||||
: FindByPrefix(call.EntityLookupText());
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// rule, N1MM included.
|
||||
private static bool IsGuantanamoException(Callsign call)
|
||||
{
|
||||
string station = call.Station;
|
||||
return station.StartsWith("KG4", StringComparison.OrdinalIgnoreCase)
|
||||
&& station.Length != 3
|
||||
&& station.Length != 5;
|
||||
}
|
||||
|
||||
public CountryLookup? Find(string call) => Find(Callsign.Parse(call));
|
||||
|
||||
@@ -63,14 +63,17 @@ public sealed class CabrilloWriter
|
||||
public string QsoLine(Qso qso)
|
||||
{
|
||||
CabrilloExchange exchange = contest.CabrilloExchange(qso, me);
|
||||
StringBuilder line = new("QSO: ");
|
||||
StringBuilder line = new(contest.IsContact(qso) ? "QSO: " : "QTC: ");
|
||||
line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' ');
|
||||
line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
||||
Append(line, exchange.Sent);
|
||||
Append(line, exchange.Received);
|
||||
line.Append(qso.RadioNumber > 1 ? '1' : '0');
|
||||
if (contest.IsContact(qso))
|
||||
{
|
||||
line.Append(qso.RadioNumber > 1 ? '1' : '0');
|
||||
}
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user