diff --git a/README.md b/README.md index 0a31fc5..bef8ec6 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,11 @@ or the same multiplier counts in full. It still goes into the Cabrillo file, under `X-QSO:` rather than `QSO:` — that is what N1MM writes, and the sponsors read it as a contact the entrant is not claiming. +Every Cabrillo line starts with its record name padded to seven characters, so +`QSO:`, `QTC:` and `X-QSO:` all put the frequency in the same column. That is +the width N1MM pads it to, and `X-QSO:` is the reason: six characters and one +space. + Correcting the country prefix by hand changes the score. The country file is a best guess for calls it has no rule for, so what the contact says now wins over what the file says. @@ -768,11 +773,23 @@ voice keyer and no digital window, so on those two modes the buttons move the cursor as they did before. N1MM's RTTY window also has a Send All button that keys the whole series at once; there is nothing to send it with here. -In a WAE contest the log window has N1MM's **QTC?** column, which holds the -series a QTC row belongs to and is empty for an ordinary contact. +In a WAE contest the log window has N1MM's two columns, whose names do not read +the way they sound: **Series** says whether the row is a contact or traffic — +`QSO`, `SQTC` or `RQTC` — and **QTC?** holds the series a QTC row belongs to, +such as `3/10`. + +The score summary splits the same way N1MM's does: contacts, traffic sent and +traffic received get a row each on every band, under a **Q/QTC** column, and a +band row carries that band's weight, so 80 metres reads 4 for one multiplier. +The band rows add up to the total. The scoring was checked against six real WAE logs, and agrees with N1MM on -points and on weighted multipliers to the contact. +points and on weighted multipliers to the contact. The Cabrillo layouts are +N1MM's, from `CWAE.CabrilloString`: a contact leaves three spaces after the sent +serial rather than the one every other column gets, a QTC line is written as a +`QTC:` record carrying the receiver, the series, the sender and then the time, +callsign and serial number being reported, and the transmitter column is written +only for a multi-operator entry. ### Band edges diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs index 4a8d41b..0b3d8d4 100644 --- a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs +++ b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs @@ -8,6 +8,7 @@ namespace Nonemm.App.Windows; public sealed partial class ScoreWindow : RefreshableWindow { private readonly AppSession session; + private int columnCount = 4; public ScoreWindow(AppSession session) { @@ -28,33 +29,37 @@ public sealed partial class ScoreWindow : RefreshableWindow return; } - List bands = session.Logging.Log.Qsos - .Select(q => q.Band) - .OfType() - .Distinct() - .OrderBy(b => b.MegahertzLabel) + // the log keeps the same split N1MM's score window shows, so the band + // rows carry the weighted multipliers and leave out what is not in the + // claimed score + List> lines = session.Logging.Log.Rows + .OrderBy(r => Bands.Named(r.Key.Band)?.MegahertzLabel ?? double.MaxValue) + .ThenBy(r => r.Key.Kind, StringComparer.Ordinal) .ToList(); + bool hasKinds = session.Logging.Contest.HasQtcTraffic; - foreach (string _ in new[] { "band", "qsos", "points", "mults" }) + columnCount = hasKinds ? 5 : 4; + for (int column = 0; column < columnCount; column++) { Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); } - AddRow(0, "Band", "QSOs", "Points", "Mults", header: true); + AddRow(0, "Band", "Q/QTC", "QSOs", "Points", "Mults", header: true); int row = 1; - foreach (Band band in bands) + foreach ((ScoreKey key, ScoreTally line) in lines) { - IReadOnlyList onBand = session.Logging.Log.Qsos.Where(q => q.Band == band).ToList(); AddRow( row++, - band.Name, - onBand.Count.ToString(), - onBand.Sum(q => q.Points).ToString(), - onBand.Sum(MultiplierCount).ToString()); + key.Band, + key.Kind, + line.Qsos.ToString(), + line.Points.ToString(), + line.TotalMultipliers.ToString()); } AddRow( row, "Total", + hasKinds ? "All" : "", session.Logging.Log.Tally.Qsos.ToString(), session.Logging.Log.Tally.Points.ToString(), session.Logging.Log.Tally.TotalMultipliers.ToString(), @@ -86,13 +91,19 @@ public sealed partial class ScoreWindow : RefreshableWindow private static string Hours(TimeSpan time) => $"{(int)time.TotalHours}:{time.Minutes:00}"; - private static int MultiplierCount(Qso qso) => - (qso.IsMultiplier1 ? 1 : 0) + (qso.IsMultiplier2 ? 1 : 0) + (qso.IsMultiplier3 ? 1 : 0); - - private void AddRow(int row, string band, string qsos, string points, string mults, bool header = false) + private void AddRow( + int row, + string band, + string kind, + string qsos, + string points, + string mults, + bool header = false) { Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - string[] cells = [band, qsos, points, mults]; + string[] cells = columnCount == 5 + ? [band, kind, qsos, points, mults] + : [band, qsos, points, mults]; for (int column = 0; column < cells.Length; column++) { TextBlock text = new() diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index a9988d7..4e82723 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -106,6 +106,12 @@ public interface Contest /// The log window then shows the series they belong to. bool HasQtcTraffic => false; + /// Which kind of row this is, for a contest whose score is broken out by + /// kind. WAE separates contacts from the traffic sent and received, and + /// N1MM's score window gives each kind a row of its own on every band. + /// Every other contest has one kind and the column is not shown. + string RowKindFor(Qso qso) => ""; + string SentExchangeFor(StationInfo me); int PointsFor(QsoContext qso); diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index d8ca863..d442757 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -14,6 +14,7 @@ public sealed class ContestLog private readonly List qsos = []; private readonly Dictionary workedKeys = new(StringComparer.Ordinal); private readonly HashSet claimedMultipliers = new(StringComparer.Ordinal); + private readonly Dictionary rows = []; private ScoreTally tally = new(); public ContestLog( @@ -42,6 +43,11 @@ public sealed class ContestLog public ScoreTally Tally => tally; + /// The same score split the way N1MM's score window splits it: a row per + /// band, and per kind of row as well where the contest has kinds. The + /// rows add up to `Tally`. + public IReadOnlyDictionary Rows => rows; + public int TotalScore => contest.TotalScore(tally, Entry); /// What logging this contact right now would do, without logging it. @@ -181,9 +187,11 @@ public sealed class ContestLog return; } bool counts = qso.IsInScore; + ScoreTally row = RowFor(qso); if (counts) { tally.AddQso(verdict.Points); + row.AddQso(verdict.Points); } if (!contest.IsContact(qso)) { @@ -196,6 +204,7 @@ public sealed class ContestLog if (counts) { tally.AddMultiplier(multiplier); + row.AddMultiplier(multiplier); } } } @@ -208,10 +217,22 @@ public sealed class ContestLog /// Rescores the whole log. A removed or edited contact can hand its /// multiplier to a later one, which only a full pass gets right. + private ScoreTally RowFor(Qso qso) + { + ScoreKey key = new(qso.Band?.Name ?? "", contest.RowKindFor(qso)); + if (!rows.TryGetValue(key, out ScoreTally? row)) + { + row = new ScoreTally(); + rows[key] = row; + } + return row; + } + private void Rebuild() { workedKeys.Clear(); claimedMultipliers.Clear(); + rows.Clear(); tally = new ScoreTally(); List ordered = qsos.OrderBy(q => q.TimestampUtc).ToList(); qsos.Clear(); diff --git a/src/Nonemm.Contests/Rules/Wae.cs b/src/Nonemm.Contests/Rules/Wae.cs index dd9f842..59fba8d 100644 --- a/src/Nonemm.Contests/Rules/Wae.cs +++ b/src/Nonemm.Contests/Rules/Wae.cs @@ -65,6 +65,12 @@ public sealed class Wae : Contest public bool HasQtcTraffic => true; + /// N1MM's score window gives contacts, traffic sent and traffic received a + /// row each on every band, under the heading `Q/QTC`. The value is what the + /// `Exchange1` column holds. + public string RowKindFor(Qso qso) => + WaeQtc.From(qso) is { } qtc ? (qtc.IsSent ? WaeQtc.Sent : WaeQtc.Received) : WaeQtc.Contact; + public string SentExchangeFor(StationInfo me) => "001"; /// A QTC line counts, and so does every contact on a contest band. The @@ -106,13 +112,20 @@ public sealed class Wae : Contest [ new CabrilloField(me.Callsign, 13), new CabrilloField(qso.SentReport, 3), - new CabrilloField($"{qso.SentNumber:0000}", 4), + // N1MM leaves three spaces after the sent serial rather + // than the one every other column gets + new CabrilloField($"{qso.SentNumber:0000}", 6), ], [ new CabrilloField(qso.Call.Text, 13), new CabrilloField(qso.ReceivedReport, 3), new CabrilloField($"{qso.ReceivedNumber:0000}", 4), - ]); + ]) + { + // N1MM writes the transmitter column for a multi-operator + // entry and nothing at all for a single operator + AddsTransmitter = entry.OperatorCategory == "MULTI-OP", + }; } string receiver = qtc.IsSent ? qtc.Station.Text : me.Callsign; string sender = qtc.IsSent ? me.Callsign : qtc.Station.Text; diff --git a/src/Nonemm.Contests/ScoreKey.cs b/src/Nonemm.Contests/ScoreKey.cs new file mode 100644 index 0000000..d07f740 --- /dev/null +++ b/src/Nonemm.Contests/ScoreKey.cs @@ -0,0 +1,6 @@ +namespace Nonemm.Contests; + +/// Which row of the score window a contact belongs to. `Kind` is empty for +/// every contest but WAE, which gives contacts, traffic sent and traffic +/// received a row each on every band. +public readonly record struct ScoreKey(string Band, string Kind); diff --git a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs index 44e4b56..0f81d88 100644 --- a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs +++ b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs @@ -62,6 +62,8 @@ public sealed class CabrilloWriter return text.ToString(); } + private const int TagWidth = 7; + public string QsoLine(Qso qso) { CabrilloExchange exchange = contest.CabrilloExchange(qso, me, entry); @@ -82,13 +84,17 @@ public sealed class CabrilloWriter /// A contact the operator has excluded from the claimed score is still /// written, under `X-QSO:` rather than `QSO:`. That is what N1MM writes, /// and the sponsors read it as a contact the entrant is not claiming. + /// + /// The tag is padded to seven so that the columns after it line up whichever + /// tag the row carries. That is the width N1MM pads it to, and `X-QSO:` is + /// why: six characters and one space. private string RecordName(Qso qso) { if (!contest.IsContact(qso)) { - return "QTC: "; + return "QTC:".PadRight(TagWidth); } - return qso.IsClaimed ? "QSO: " : "X-QSO: "; + return (qso.IsClaimed ? "QSO:" : "X-QSO:").PadRight(TagWidth); } /// Every column is the width the sponsor asks for and one space, and a diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index 95a0aa3..c296de1 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -245,9 +245,10 @@ public sealed class QsoEditor } if (contest.HasQtcTraffic) { - // N1MM's two headers for WAE: the exchange column says whether the - // row is a contact or QTC traffic — `QSO`, `SQTC` or `RQTC` — and - // the misc column holds the series a QTC row belongs to + // N1MM's two headers for WAE, which do not read the way they + // sound: `Series` is the exchange column and says whether the row + // is a contact or QTC traffic — `QSO`, `SQTC` or `RQTC` — and + // `QTC?` is the misc column and holds the series, such as `3/10` columns.Add(new QsoColumn("Series", QsoField.Exchange1, 6)); columns.Add(new QsoColumn("QTC?", QsoField.MiscText, 6)); } diff --git a/src/Nonemm.Session/QtcTraffic.cs b/src/Nonemm.Session/QtcTraffic.cs index aa41e33..b060562 100644 --- a/src/Nonemm.Session/QtcTraffic.cs +++ b/src/Nonemm.Session/QtcTraffic.cs @@ -102,7 +102,8 @@ public sealed class QtcTraffic /// The contacts to report to this station: the ones not reported to anybody /// yet, oldest first, and never the station itself. Ten at a time unless /// the operator asks for a shorter series, and fewer when the station has - /// already had some. + /// already had some. A contact marked as a dupe is not offered, which is + /// what N1MM's `QTCRecordsetCount` leaves out. public IReadOnlyList ToSend(Callsign other, int mostPerSeries = WaeQtc.MostPerSeries) { int room = Math.Min(Math.Clamp(mostPerSeries, 1, WaeQtc.MostPerSeries), Remaining(other)); @@ -116,6 +117,7 @@ public sealed class QtcTraffic .ToHashSet(StringComparer.OrdinalIgnoreCase); List worth = position.Log.Qsos .Where(q => !WaeQtc.IsQtc(q)) + .Where(q => q.ContactType.Length == 0) .Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase)) .Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber))) .OrderBy(q => q.TimestampUtc) diff --git a/tests/Nonemm.Contests.Tests/WaeTests.cs b/tests/Nonemm.Contests.Tests/WaeTests.cs index ab6a7f3..ccd6020 100644 --- a/tests/Nonemm.Contests.Tests/WaeTests.cs +++ b/tests/Nonemm.Contests.Tests/WaeTests.cs @@ -115,6 +115,32 @@ public class WaeTests Assert.Empty(log.Judge(On("D2X", 14_025)).NewMultipliers); } + /// N1MM's score window gives contacts, traffic sent and traffic received a + /// row of their own on every band, and the multipliers in a band row carry + /// that band's weight: four on 80, three on 40, two on 20, 15 and 10. + [Fact] + public void TheScoreSplitsByBandAndByKindOfRow() + { + ContestLog log = LogFor(Cw, TestLog.Germany); + log.Add(On("JA1XYZ", 3_525)); + log.Add(On("K1ABC", 14_025)); + log.Add(new WaeQtc( + Callsign.Parse("JA1XYZ"), IsSent: false, 1, 10, "1435", Callsign.Parse("W1AW"), 42) + .ApplyTo(On("JA1XYZ", 3_525))); + + Assert.Equal(4, log.Rows[new ScoreKey("80M", WaeQtc.Contact)].TotalMultipliers); + Assert.Equal(1, log.Rows[new ScoreKey("80M", WaeQtc.Contact)].Points); + Assert.Equal(2, log.Rows[new ScoreKey("20M", WaeQtc.Contact)].TotalMultipliers); + + ScoreTally traffic = log.Rows[new ScoreKey("80M", WaeQtc.Received)]; + Assert.Equal(1, traffic.Qsos); + Assert.Equal(1, traffic.Points); + Assert.Equal(0, traffic.TotalMultipliers); + + Assert.Equal(log.Tally.Points, log.Rows.Values.Sum(r => r.Points)); + Assert.Equal(log.Tally.TotalMultipliers, log.Rows.Values.Sum(r => r.TotalMultipliers)); + } + [Fact] public void AQtcIsAPointAndNothingElse() { diff --git a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs index d366019..66bada3 100644 --- a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs +++ b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs @@ -36,7 +36,7 @@ public class CabrilloWriterTests { CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me); string line = writer.QsoLine(Contact()); - Assert.StartsWith("QSO: 14025 CW 2026-05-30 1234 DL1ABC", line); + Assert.StartsWith("QSO: 14025 CW 2026-05-30 1234 DL1ABC", line); Assert.Contains("JA1XYZ", line); Assert.EndsWith("0", line); Assert.Contains(" 14 ", line); @@ -84,7 +84,7 @@ public class CabrilloWriterTests new CabrilloHeader { Contest = "CQ-WW-CW", Callsign = "DL1ABC" }, [Contact() with { IsClaimed = false }]); Assert.Contains("\r\nX-QSO: ", log); - Assert.DoesNotContain("\r\nQSO: ", log); + Assert.DoesNotContain("\r\nQSO:", log); } /// The three contests where a DX entrant sends a serial. N1MM writes the @@ -105,6 +105,41 @@ public class CabrilloWriterTests new CabrilloWriter(new YoDxHf(), Me).QsoLine(qso)); } + private static string WaeLine(string tag, string exchange) => + tag.PadRight(7) + " 3799 PH 2026-05-30 1234 " + exchange; + + /// WAE writes contacts and QTC traffic under different tags and in + /// different columns. N1MM's layouts are in `CWAE.CabrilloString`: a + /// contact leaves three spaces after the sent serial, and a QTC line is + /// the receiver, the series, the sender, then the time, callsign and + /// serial number of the contact being reported. + [Fact] + public void WaeWritesContactsAndTrafficUnderTheirOwnTags() + { + CabrilloWriter writer = new(new Wae(ModeCategory.Phone), Me); + Qso contact = Contact(3_799, Modes.Usb) with { SentNumber = 12, ReceivedNumber = 34 }; + Assert.Equal( + WaeLine("QSO:", "DL1ABC 599 0012 JA1XYZ 599 0034 "), + writer.QsoLine(contact)); + + Qso sent = new WaeQtc( + Callsign.Parse("JA1XYZ"), + IsSent: true, + Series: 3, + CountInSeries: 10, + TimeUtc: "1201", + Call: Callsign.Parse("G3ABC"), + Number: 45).ApplyTo(contact); + Assert.Equal( + WaeLine("QTC:", "JA1XYZ 3/10 DL1ABC 1201 G3ABC 0045 "), + writer.QsoLine(sent)); + + Qso received = sent with { Exchange1 = WaeQtc.Received }; + Assert.Equal( + WaeLine("QTC:", "DL1ABC 3/10 JA1XYZ 1201 G3ABC 0045 "), + writer.QsoLine(received)); + } + /// The SA 10 file, whose sponsor asks for its own columns: the operator's /// call in the first 13, then every column padded to the width the file /// gives, with one space after it. @@ -123,7 +158,7 @@ public class CabrilloWriterTests string line = writer.QsoLine(Contact() with { Exchange1 = "25" }); Assert.Equal( - "QSO: 14025 CW 2026-05-30 1234 DL1ABC 599 14 JA1XYZ 599 25 ", + "QSO: 14025 CW 2026-05-30 1234 DL1ABC 599 14 JA1XYZ 599 25 ", line); } @@ -153,7 +188,7 @@ public class CabrilloWriterTests Contact(14_025, Modes.Rtty) with { MiscText = "1704", Exchange1 = "905" }); Assert.Equal( - "QSO: 14025 RY 2026-05-30 1234 " + "QSO: 14025 RY 2026-05-30 1234 " + "DL1ABC 599 0012 1704 " + "JA1XYZ 599 0034 0905 0", line); @@ -186,7 +221,7 @@ public class CabrilloWriterTests string line = writer.QsoLine(Contact() with { Name = "SAM", Exchange1 = "OH" }); Assert.Equal( - "QSO: 14025 CW 2026-05-30 1234 " + "QSO: 14025 CW 2026-05-30 1234 " + "DL1ABC 12 JOE OH " + "JA1XYZ 34 SAM OH 0", line); @@ -209,7 +244,7 @@ public class CabrilloWriterTests string line = writer.QsoLine(Contact() with { Name = "SAM", Exchange1 = "OH" }); Assert.Equal( - "QSO: 14025 CW 2026-05-30 1234 " + "QSO: 14025 CW 2026-05-30 1234 " + "DL1ABC 12 JOE OH " + "JA1XYZ 34 SAM OH 0", line); @@ -232,7 +267,7 @@ public class CabrilloWriterTests Contact() with { Precedence = "B", Check = 77, Section = "OH" }); Assert.Equal( - "QSO: 14025 CW 2026-05-30 1234 " + "QSO: 14025 CW 2026-05-30 1234 " + "DL1ABC 0012 A 55 OH " + "JA1XYZ 0034 B 77 OH ", line); @@ -254,7 +289,7 @@ public class CabrilloWriterTests string line = writer.QsoLine(Contact() with { Section = "OH" }); Assert.Equal( - "QSO: 14025 CW 2026-05-30 1234 " + "QSO: 14025 CW 2026-05-30 1234 " + "DL1ABC OH012 " + "JA1XYZ OH034 ", line);