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:
22
README.md
22
README.md
@@ -41,7 +41,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests |
|
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests |
|
||||||
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
|
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
|
||||||
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
|
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
|
||||||
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, packet |
|
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, packet |
|
||||||
@@ -265,6 +265,26 @@ are a drawing aid: a contact logs either side of a boundary just the same.
|
|||||||
Bands where N1MM's own numbers contradict themselves — 60M, and everything
|
Bands where N1MM's own numbers contradict themselves — 60M, and everything
|
||||||
above 2M — get no shading rather than a guess.
|
above 2M — get no shading rather than a guess.
|
||||||
|
|
||||||
|
### Worked All Europe
|
||||||
|
|
||||||
|
WAE is scored differently from everything else here. A multiplier counts four
|
||||||
|
times on 80 metres, three times on 40 and twice on 20, 15 and 10, and half the
|
||||||
|
score is QTC traffic: one station reports contacts it has already made to the
|
||||||
|
station it is working, and every line reported is a point for both of them.
|
||||||
|
|
||||||
|
The QTC lines live in the log as rows of their own, which is where N1MM keeps
|
||||||
|
them — `Exchange1` says `SQTC` or `RQTC`, the time, callsign and serial number
|
||||||
|
of the contact being reported go in the sent report, received report and sent
|
||||||
|
number columns, and the series such as `9/10` goes in the misc text column. A
|
||||||
|
log written by either program therefore opens in the other with its QTCs
|
||||||
|
intact, and a QTC row is written to the Cabrillo file as a `QTC:` record.
|
||||||
|
|
||||||
|
**Entering QTCs while operating is not there yet.** The rules, the scoring and
|
||||||
|
the file formats are; the window for taking traffic down is next.
|
||||||
|
|
||||||
|
The scoring was checked against six real WAE logs, and agrees with N1MM on
|
||||||
|
points and on weighted multipliers to the contact.
|
||||||
|
|
||||||
### Band edges
|
### Band edges
|
||||||
|
|
||||||
A contact just outside a band edge keeps its band. `Bands.ForFrequency` first
|
A contact just outside a band edge keeps its band. `Bands.ForFrequency` first
|
||||||
|
|||||||
@@ -39,7 +39,18 @@ and passed over. Acting on them means holding N1MM's section lists and its
|
|||||||
rules about which sections are retired, and getting that wrong throws away good
|
rules about which sections are retired, and getting that wrong throws away good
|
||||||
data. The file is read without them.
|
data. The file is read without them.
|
||||||
|
|
||||||
**QTC handling for WAE.** Not started. WAE logs will not be complete without it.
|
**Entering WAE QTC traffic.** The contest, the QTC rows, the scoring and the
|
||||||
|
Cabrillo `QTC:` records are in, and a log written by N1MM reads back with its
|
||||||
|
QTCs. What is missing is the window an operator takes traffic in: today a QTC
|
||||||
|
can only reach the log by being read from a file N1MM wrote.
|
||||||
|
|
||||||
|
**Two WAE numbers do not agree with N1MM, both from 2020 and 2021 logs.** The
|
||||||
|
disagreements are the country file of the day, not the rules: 4U1A counted as
|
||||||
|
`4U1V` then and as `OE` in today's `wl_cty.dat`, `KP2BH` counted as `KP2` then
|
||||||
|
and is listed as a US call now, and China was not split by call area yet. The
|
||||||
|
2022 to 2025 logs agree to the contact. One difference is ours on purpose: N1MM
|
||||||
|
counts a callsign it cannot place — `D1M` — as a multiplier with an empty
|
||||||
|
value, and we do not.
|
||||||
|
|
||||||
**Digital modes.** A contact can be logged as RTTY or another digital mode, and
|
**Digital modes.** A contact can be logged as RTTY or another digital mode, and
|
||||||
the contest rules score it, but there is no digital window: no decoding, no
|
the contest rules score it, but there is no digital window: no decoding, no
|
||||||
|
|||||||
BIN
om5m.s3db-shm
Normal file
BIN
om5m.s3db-shm
Normal file
Binary file not shown.
0
om5m.s3db-wal
Normal file
0
om5m.s3db-wal
Normal file
@@ -34,6 +34,11 @@ public interface Contest
|
|||||||
|
|
||||||
int PointsFor(QsoContext qso);
|
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);
|
IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso);
|
||||||
|
|
||||||
int TotalScore(ScoreTally tally);
|
int TotalScore(ScoreTally tally);
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public sealed class ContestLog
|
|||||||
public Verdict Judge(Qso candidate)
|
public Verdict Judge(Qso candidate)
|
||||||
{
|
{
|
||||||
QsoContext context = ContextFor(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)))
|
if (contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate)))
|
||||||
{
|
{
|
||||||
return Verdict.Dupe;
|
return Verdict.Dupe;
|
||||||
@@ -85,7 +89,9 @@ public sealed class ContestLog
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool IsWorked(Qso candidate) =>
|
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.
|
/// Every contact with this call, newest first.
|
||||||
public IReadOnlyList<Qso> WorkedBefore(string call) =>
|
public IReadOnlyList<Qso> WorkedBefore(string call) =>
|
||||||
@@ -106,12 +112,16 @@ public sealed class ContestLog
|
|||||||
|
|
||||||
private void Index(Qso qso, Verdict verdict)
|
private void Index(Qso qso, Verdict verdict)
|
||||||
{
|
{
|
||||||
workedKeys.Add(DupeKey(qso));
|
|
||||||
tally.AddQso(verdict.Points);
|
tally.AddQso(verdict.Points);
|
||||||
|
if (!contest.IsContact(qso))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workedKeys.Add(DupeKey(qso));
|
||||||
foreach (Multiplier multiplier in verdict.NewMultipliers)
|
foreach (Multiplier multiplier in verdict.NewMultipliers)
|
||||||
{
|
{
|
||||||
claimedMultipliers.Add(MultiplierKey(multiplier));
|
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("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("IARU", "IARU HF World Championship", [ModeCategory.Cw, ModeCategory.Phone], _ => new IaruHf()),
|
||||||
new("SS", "ARRL Sweepstakes", [ModeCategory.Cw, ModeCategory.Phone], m => new Sweepstakes(m)),
|
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("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("NAQP", "North American QSO Party", [ModeCategory.Cw, ModeCategory.Phone, ModeCategory.Digital], m => new NorthAmericanQsoParty(m)),
|
||||||
new("DX", "General logging", [], _ => new GeneralLogging()),
|
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
|
/// 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.
|
/// 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}";
|
public override string ToString() => Scope.Length == 0 ? Value : $"{Value}/{Scope}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public static class N1mmContestNames
|
|||||||
["CQWPXCW"] = ("CQWPX", ModeCategory.Cw),
|
["CQWPXCW"] = ("CQWPX", ModeCategory.Cw),
|
||||||
["CQWPXSSB"] = ("CQWPX", ModeCategory.Phone),
|
["CQWPXSSB"] = ("CQWPX", ModeCategory.Phone),
|
||||||
["CQWPXRTTY"] = ("CQWPX", ModeCategory.Digital),
|
["CQWPXRTTY"] = ("CQWPX", ModeCategory.Digital),
|
||||||
|
["WAECW"] = ("WAE", ModeCategory.Cw),
|
||||||
|
["WAESSB"] = ("WAE", ModeCategory.Phone),
|
||||||
|
["WAERTTY"] = ("WAE", ModeCategory.Digital),
|
||||||
["ARRLDXCW"] = ("ARRLDX", ModeCategory.Cw),
|
["ARRLDXCW"] = ("ARRLDX", ModeCategory.Cw),
|
||||||
["ARRLDXSSB"] = ("ARRLDX", ModeCategory.Phone),
|
["ARRLDXSSB"] = ("ARRLDX", ModeCategory.Phone),
|
||||||
["SSCW"] = ("SS", ModeCategory.Cw),
|
["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; }
|
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) =>
|
public int MultiplierCount(int index) =>
|
||||||
multipliers.TryGetValue(index, out int count) ? count : 0;
|
multipliers.TryGetValue(index, out int count) ? count : 0;
|
||||||
|
|
||||||
@@ -26,9 +28,9 @@ public sealed class ScoreTally
|
|||||||
Points -= points;
|
Points -= points;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddMultiplier(int index) =>
|
public void AddMultiplier(Multiplier multiplier) =>
|
||||||
multipliers[index] = MultiplierCount(index) + 1;
|
multipliers[multiplier.Index] = MultiplierCount(multiplier.Index) + multiplier.Weight;
|
||||||
|
|
||||||
public void RemoveMultiplier(int index) =>
|
public void RemoveMultiplier(Multiplier multiplier) =>
|
||||||
multipliers[index] = MultiplierCount(index) - 1;
|
multipliers[multiplier.Index] = MultiplierCount(multiplier.Index) - multiplier.Weight;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public sealed record Callsign
|
|||||||
|
|
||||||
public bool IsMaritimeMobile => Modifiers.Contains("MM");
|
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");
|
public bool IsAeronauticalMobile => Modifiers.Contains("AM");
|
||||||
|
|
||||||
/// Maritime and aeronautical mobile stations count for no country and no
|
/// 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 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));
|
public CountryLookup? Find(string call) => Find(Callsign.Parse(call));
|
||||||
|
|||||||
@@ -63,14 +63,17 @@ public sealed class CabrilloWriter
|
|||||||
public string QsoLine(Qso qso)
|
public string QsoLine(Qso qso)
|
||||||
{
|
{
|
||||||
CabrilloExchange exchange = contest.CabrilloExchange(qso, me);
|
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(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' ');
|
||||||
line.Append(qso.Mode.CabrilloCode.PadRight(2)).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("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' ');
|
||||||
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
||||||
Append(line, exchange.Sent);
|
Append(line, exchange.Sent);
|
||||||
Append(line, exchange.Received);
|
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();
|
return line.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ public static class TestLog
|
|||||||
JA,JH,JR,7K;
|
JA,JH,JR,7K;
|
||||||
Brazil: 11: 15: SA: -10.00: 55.00: 3.0: PY:
|
Brazil: 11: 15: SA: -10.00: 55.00: 3.0: PY:
|
||||||
PY,PP;
|
PY,PP;
|
||||||
|
Asiatic Russia: 17: 30: AS: 55.88: -84.10: -7.0: UA9:
|
||||||
|
RM8,RM9,RM0,UA8,UA9,UA0,R8,R9,R0;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
public static readonly CountryFile CountryFile = CountryFile.Parse(Countries);
|
public static readonly CountryFile CountryFile = CountryFile.Parse(Countries);
|
||||||
|
|||||||
164
tests/Nonemm.Contests.Tests/WaeTests.cs
Normal file
164
tests/Nonemm.Contests.Tests/WaeTests.cs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
using Nonemm.Contests.Rules;
|
||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Contests.Tests;
|
||||||
|
|
||||||
|
public class WaeTests
|
||||||
|
{
|
||||||
|
private static readonly Wae Cw = new(ModeCategory.Cw);
|
||||||
|
private static readonly Wae Rtty = new(ModeCategory.Digital);
|
||||||
|
|
||||||
|
private static ContestLog LogFor(Contest contest, StationInfo me) =>
|
||||||
|
new(contest, me, TestLog.CountryFile);
|
||||||
|
|
||||||
|
private static Qso On(string call, double kilohertz, Mode? mode = null) =>
|
||||||
|
TestLog.Contact(call, kilohertz: kilohertz, mode: mode ?? Modes.Cw, exchange: WaeQtc.Contact);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryContactOnAContestBandIsOnePoint()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Assert.Equal(1, log.Judge(On("JA1XYZ", 14_025)).Points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WAE does not run on the WARC bands.
|
||||||
|
[Fact]
|
||||||
|
public void AContactOnAWarcBandScoresNothing()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Assert.Equal(0, log.Judge(On("JA1XYZ", 18_080)).Points);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EuropeDoesNotWorkEuropeOnCw()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Verdict verdict = log.Judge(On("IK2XYZ", 14_025));
|
||||||
|
|
||||||
|
Assert.Equal(0, verdict.Points);
|
||||||
|
Assert.Empty(verdict.NewMultipliers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnRttyEverybodyWorksEverybody()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Rtty, TestLog.Germany);
|
||||||
|
Verdict verdict = log.Judge(On("IK2XYZ", 14_080, Modes.Rtty));
|
||||||
|
|
||||||
|
Assert.Equal(1, verdict.Points);
|
||||||
|
Assert.Single(verdict.NewMultipliers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multiplier is worth four on 80 metres, three on 40 and two higher up.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(3_530, 4)]
|
||||||
|
[InlineData(7_030, 3)]
|
||||||
|
[InlineData(14_030, 2)]
|
||||||
|
[InlineData(21_030, 2)]
|
||||||
|
[InlineData(28_030, 2)]
|
||||||
|
public void AMultiplierIsWorthMoreOnTheLowBands(double kilohertz, int weight)
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Multiplier multiplier = Assert.Single(log.Judge(On("JA1XYZ", kilohertz)).NewMultipliers);
|
||||||
|
|
||||||
|
Assert.Equal(weight, multiplier.Weight);
|
||||||
|
Assert.Equal("JA1", multiplier.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheWeightGoesIntoTheScore()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
log.Add(On("JA1XYZ", 3_530));
|
||||||
|
|
||||||
|
Assert.Equal(1, log.Tally.Points);
|
||||||
|
Assert.Equal(4, log.Tally.TotalMultipliers);
|
||||||
|
Assert.Equal(4, log.TotalScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The countries with call areas count each area separately.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("K1ABC", "K1")]
|
||||||
|
[InlineData("W6XYZ", "K6")]
|
||||||
|
[InlineData("JA1XYZ", "JA1")]
|
||||||
|
[InlineData("PY2ABC", "PY2")]
|
||||||
|
public void ACallAreaIsAMultiplierOfItsOwn(string call, string expected)
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Multiplier multiplier = Assert.Single(log.Judge(On(call, 14_025)).NewMultipliers);
|
||||||
|
|
||||||
|
Assert.Equal(expected, multiplier.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asiatic Russia goes by the call area digit whatever the prefix on the
|
||||||
|
/// air, so RM0W and UA0AB are the same multiplier.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("RM0W")]
|
||||||
|
[InlineData("UA0AB")]
|
||||||
|
public void AsiaticRussiaCountsByCallArea(string call)
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Multiplier multiplier = Assert.Single(log.Judge(On(call, 14_025)).NewMultipliers);
|
||||||
|
|
||||||
|
Assert.Equal("UA0", multiplier.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM counts a callsign it cannot place as a multiplier with an empty
|
||||||
|
/// value, which inflates the score. A call with no country is no
|
||||||
|
/// multiplier here.
|
||||||
|
[Fact]
|
||||||
|
public void ACallWithNoCountryIsNoMultiplier()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Cw, TestLog.Germany);
|
||||||
|
Assert.Empty(log.Judge(On("D1M", 14_025)).NewMultipliers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AQtcIsAPointAndNothingElse()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Rtty, TestLog.Germany);
|
||||||
|
Qso qtc = new WaeQtc(
|
||||||
|
Callsign.Parse("JA1XYZ"), IsSent: true, 1, 10, "1435", Callsign.Parse("DL9XYZ"), 42)
|
||||||
|
.ApplyTo(On("JA1XYZ", 14_080, Modes.Rtty));
|
||||||
|
|
||||||
|
Verdict verdict = log.Judge(qtc);
|
||||||
|
Assert.Equal(1, verdict.Points);
|
||||||
|
Assert.Empty(verdict.NewMultipliers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The station is worked once and then reported to ten times over; those
|
||||||
|
/// rows must not read as dupes of the contact.
|
||||||
|
[Fact]
|
||||||
|
public void QtcTrafficWithAStationAlreadyWorkedIsNotADupe()
|
||||||
|
{
|
||||||
|
ContestLog log = LogFor(Rtty, TestLog.Germany);
|
||||||
|
log.Add(On("JA1XYZ", 14_080, Modes.Rtty));
|
||||||
|
Qso qtc = new WaeQtc(
|
||||||
|
Callsign.Parse("JA1XYZ"), IsSent: true, 1, 10, "1435", Callsign.Parse("DL9XYZ"), 42)
|
||||||
|
.ApplyTo(On("JA1XYZ", 14_080, Modes.Rtty));
|
||||||
|
|
||||||
|
Assert.False(log.Judge(qtc).IsDupe);
|
||||||
|
log.Add(qtc);
|
||||||
|
Assert.Equal(2, log.Tally.Points);
|
||||||
|
Assert.Equal(2, log.Tally.TotalMultipliers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AQtcGoesIntoTheLogTheWayN1mmKeepsIt()
|
||||||
|
{
|
||||||
|
WaeQtc qtc = new(
|
||||||
|
Callsign.Parse("H2X"), IsSent: true, 9, 10, "0107", Callsign.Parse("OH1SIC"), 7);
|
||||||
|
Qso row = qtc.ApplyTo(On("H2X", 14_099, Modes.Rtty));
|
||||||
|
|
||||||
|
Assert.Equal("SQTC", row.Exchange1);
|
||||||
|
Assert.Equal("9/10", row.MiscText);
|
||||||
|
Assert.Equal("0107", row.SentReport);
|
||||||
|
Assert.Equal("OH1SIC", row.ReceivedReport);
|
||||||
|
Assert.Equal(7, row.SentNumber);
|
||||||
|
Assert.Equal(qtc, WaeQtc.From(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnOrdinaryContactIsNotAQtc() =>
|
||||||
|
Assert.Null(WaeQtc.From(On("JA1XYZ", 14_025)));
|
||||||
|
}
|
||||||
@@ -69,4 +69,12 @@ public class CallsignTests
|
|||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
public void ATextThatCouldNotBeACallIsNot(string text) =>
|
public void ATextThatCouldNotBeACallIsNot(string text) =>
|
||||||
Assert.False(Callsign.Parse(text).IsPlausible);
|
Assert.False(Callsign.Parse(text).IsPlausible);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoParsesOfOneCallAreTheSameCallsign()
|
||||||
|
{
|
||||||
|
Assert.Equal(Callsign.Parse("OH1SIC"), Callsign.Parse("OH1SIC"));
|
||||||
|
Assert.Equal(Callsign.Parse("KH9/N8BJQ"), Callsign.Parse("KH9/N8BJQ"));
|
||||||
|
Assert.NotEqual(Callsign.Parse("OH1SIC"), Callsign.Parse("OH1SID"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user