From 95e1405be1d41bfa71729318d107a31f9e105766 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 10 Sep 2026 11:07:09 +0000 Subject: [PATCH] Check the signal report before it goes in the log The log window took any text in the two report columns. It now checks the report the same way it checks the zone or the section: RST on most modes, and a signal-to-noise ratio in decibels on FT8, FT4, JT65, Q65 and MSK144. A WAE QTC row is skipped, because it keeps the time sent and the other station's call in those two columns. The grid column is checked on every contest now, not only when the contest asks for a locator, and the value is stored as JN88dd. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf --- src/Nonemm.Core/Mode.cs | 4 ++ src/Nonemm.Core/Modes.cs | 15 ++++-- src/Nonemm.Core/SignalReport.cs | 37 ++++++++++++++ src/Nonemm.Session/QsoColumn.cs | 7 +-- src/Nonemm.Session/QsoEditor.cs | 52 +++++++++++++++++--- src/Nonemm.Session/QsoField.cs | 7 ++- tests/Nonemm.Core.Tests/SignalReportTests.cs | 41 +++++++++++++++ tests/Nonemm.Session.Tests/QsoEditorTests.cs | 51 +++++++++++++++++++ 8 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 src/Nonemm.Core/SignalReport.cs create mode 100644 tests/Nonemm.Core.Tests/SignalReportTests.cs diff --git a/src/Nonemm.Core/Mode.cs b/src/Nonemm.Core/Mode.cs index 879afeb..c29abca 100644 --- a/src/Nonemm.Core/Mode.cs +++ b/src/Nonemm.Core/Mode.cs @@ -3,5 +3,9 @@ namespace Nonemm.Core; /// One operating mode. `Name` is what goes in the log and must round-trip. public sealed record Mode(string Name, ModeCategory Category, string CabrilloCode, string AdifName) { + /// True for the weak-signal modes, which exchange a signal-to-noise ratio + /// in decibels rather than RST. + public bool ReportsDecibels { get; init; } + public override string ToString() => Name; } diff --git a/src/Nonemm.Core/Modes.cs b/src/Nonemm.Core/Modes.cs index 1b13c09..55b85b9 100644 --- a/src/Nonemm.Core/Modes.cs +++ b/src/Nonemm.Core/Modes.cs @@ -11,12 +11,17 @@ public static class Modes public static readonly Mode Rtty = new("RTTY", ModeCategory.Digital, "RY", "RTTY"); public static readonly Mode Psk31 = new("PSK31", ModeCategory.Digital, "DG", "PSK31"); public static readonly Mode Psk63 = new("PSK63", ModeCategory.Digital, "DG", "PSK63"); - public static readonly Mode Ft8 = new("FT8", ModeCategory.Digital, "DG", "FT8"); - public static readonly Mode Ft4 = new("FT4", ModeCategory.Digital, "DG", "FT4"); + public static readonly Mode Ft8 = + new("FT8", ModeCategory.Digital, "DG", "FT8") { ReportsDecibels = true }; + public static readonly Mode Ft4 = + new("FT4", ModeCategory.Digital, "DG", "FT4") { ReportsDecibels = true }; public static readonly Mode Mfsk = new("MFSK", ModeCategory.Digital, "DG", "MFSK"); - public static readonly Mode Jt65 = new("JT65", ModeCategory.Digital, "DG", "JT65"); - public static readonly Mode Msk144 = new("MSK144", ModeCategory.Digital, "DG", "MSK144"); - public static readonly Mode Q65 = new("Q65", ModeCategory.Digital, "DG", "Q65"); + public static readonly Mode Jt65 = + new("JT65", ModeCategory.Digital, "DG", "JT65") { ReportsDecibels = true }; + public static readonly Mode Msk144 = + new("MSK144", ModeCategory.Digital, "DG", "MSK144") { ReportsDecibels = true }; + public static readonly Mode Q65 = + new("Q65", ModeCategory.Digital, "DG", "Q65") { ReportsDecibels = true }; public static readonly Mode Digital = new("DIGI", ModeCategory.Digital, "DG", "DATA"); public static readonly IReadOnlyList All = diff --git a/src/Nonemm.Core/SignalReport.cs b/src/Nonemm.Core/SignalReport.cs new file mode 100644 index 0000000..7fcf0b8 --- /dev/null +++ b/src/Nonemm.Core/SignalReport.cs @@ -0,0 +1,37 @@ +using System.Globalization; + +namespace Nonemm.Core; + +/// Checks a signal report before it goes in the log. Most modes exchange RST; +/// the weak-signal modes exchange a signal-to-noise ratio in decibels instead. +public static class SignalReport +{ + private const int Weakest = -50; + private const int Strongest = 50; + + public static bool IsPlausible(string text, Mode mode) + { + string report = text.Trim(); + return mode.ReportsDecibels ? IsDecibels(report) : IsRst(report); + } + + /// What to type, for an error message. + public static string Example(Mode mode) => mode.ReportsDecibels + ? "-12" + : mode.Category == ModeCategory.Phone ? "59" : "599"; + + /// Readability 1 to 5, strength 1 to 9 and, on CW and the digital modes, + /// tone 1 to 9. Two figures pass on every mode: phone sends two, and an + /// operator on CW may drop the tone. + private static bool IsRst(string report) => + report.Length is 2 or 3 + && report[0] is >= '1' and <= '5' + && report[1] is >= '1' and <= '9' + && (report.Length == 2 || report[2] is >= '1' and <= '9'); + + private static bool IsDecibels(string report) => + int.TryParse(report, NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, out int decibels) + && decibels >= Weakest + && decibels <= Strongest; +} diff --git a/src/Nonemm.Session/QsoColumn.cs b/src/Nonemm.Session/QsoColumn.cs index 3fa998d..ca9f564 100644 --- a/src/Nonemm.Session/QsoColumn.cs +++ b/src/Nonemm.Session/QsoColumn.cs @@ -1,5 +1,6 @@ namespace Nonemm.Session; -/// One editable column of the log window. -/// `Width` is in characters. -public sealed record QsoColumn(string Label, QsoField Field, int Width); +/// One column of the log window. +/// `Width` is in characters. A column that is not editable is worked out from +/// the contact rather than typed in, such as the points it scored. +public sealed record QsoColumn(string Label, QsoField Field, int Width, bool IsEditable = true); diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs index c296de1..00a8881 100644 --- a/src/Nonemm.Session/QsoEditor.cs +++ b/src/Nonemm.Session/QsoEditor.cs @@ -1,6 +1,7 @@ using System.Globalization; using Nonemm.Contests; using Nonemm.Contests.Multipliers; +using Nonemm.Contests.Rules; using Nonemm.Core; using Nonemm.Core.Country; @@ -22,7 +23,8 @@ public sealed class QsoEditor Columns = BuildColumns(contest, me, exchangeKinds); } - /// Log columns, left to right. + /// The log columns this contest opens with, left to right. The operator can + /// pick a different set; see `LogColumns`. public IReadOnlyList Columns { get; } public static string Read(Qso qso, QsoField field) => field switch @@ -52,6 +54,11 @@ public sealed class QsoEditor QsoField.WpxPrefix => qso.WpxPrefix, QsoField.RadioNumber => Digits(qso.RadioNumber), QsoField.RunPosition => Digits(qso.RunPosition), + QsoField.Points => qso.Points.ToString(CultureInfo.InvariantCulture), + QsoField.Multipliers => string.Concat( + qso.IsMultiplier1 ? "1" : "", + qso.IsMultiplier2 ? "2" : "", + qso.IsMultiplier3 ? "3" : ""), _ => "", }; @@ -94,9 +101,9 @@ public sealed class QsoEditor QsoField.QsxFrequency => WithFrequency(value, f => qso with { QsxFrequency = f }, allowEmpty: true), QsoField.Mode => WithMode(qso, value), QsoField.Call => WithCall(qso, value), - QsoField.SentReport => QsoEdit.Accept(qso with { SentReport = value }), + QsoField.SentReport => WithReport(qso, value, r => qso with { SentReport = r }), QsoField.SentNumber => Count(value, n => qso with { SentNumber = n }), - QsoField.ReceivedReport => QsoEdit.Accept(qso with { ReceivedReport = value }), + QsoField.ReceivedReport => WithReport(qso, value, r => qso with { ReceivedReport = r }), QsoField.ReceivedNumber => Count(value, n => qso with { ReceivedNumber = n }), QsoField.Zone => Count(value, n => qso with { Zone = n }), QsoField.Section => QsoEdit.Accept(qso with { Section = value }), @@ -106,7 +113,7 @@ public sealed class QsoEditor QsoField.MiscText => QsoEdit.Accept(qso with { MiscText = value }), QsoField.Name => QsoEdit.Accept(qso with { Name = value }), QsoField.Qth => QsoEdit.Accept(qso with { Qth = value }), - QsoField.GridSquare => QsoEdit.Accept(qso with { GridSquare = value }), + QsoField.GridSquare => WithGrid(qso, value), QsoField.Power => QsoEdit.Accept(qso with { Power = value }), QsoField.Comment => QsoEdit.Accept(qso with { Comment = value }), QsoField.Operator => QsoEdit.Accept(qso with { Operator = value }), @@ -165,6 +172,31 @@ public sealed class QsoEditor return QsoEdit.Accept(set(Frequency.FromKilohertz(kilohertz))); } + /// A WAE QTC row keeps the time sent and the other station's call in the + /// two report columns, so there is no report there to check. + private static QsoEdit WithReport(Qso qso, string value, Func set) + { + if (value.Length == 0 || WaeQtc.IsQtc(qso) || SignalReport.IsPlausible(value, qso.Mode)) + { + return QsoEdit.Accept(set(value)); + } + return QsoEdit.Refuse( + $"{value} is not a signal report on {qso.Mode.Name}; write it as {SignalReport.Example(qso.Mode)}"); + } + + /// The locator is stored the way it is written: upper case field and + /// square, lower case subsquare. + private static QsoEdit WithGrid(Qso qso, string value) + { + if (value.Length == 0) + { + return QsoEdit.Accept(qso with { GridSquare = "" }); + } + return GridSquare.TryParse(value, out GridSquare grid) + ? QsoEdit.Accept(qso with { GridSquare = grid.Text }) + : QsoEdit.Refuse($"{value} is not a grid square"); + } + private static QsoEdit WithMode(Qso qso, string value) { Mode? mode = Modes.Parse(value); @@ -237,11 +269,14 @@ public sealed class QsoEditor { columns.Add(new QsoColumn("SntNr", QsoField.SentNumber, 5)); } - foreach (ExchangeField field in contest.LogFieldsFor(me).Where(f => !f.IsSent)) + foreach (ExchangeField field in contest.LogFieldsFor(me)) { QsoField mapped = FieldFor(field.Slot); kinds[mapped] = field.Kind; - columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1))); + if (!field.IsSent) + { + columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length + 1))); + } } if (contest.HasQtcTraffic) { @@ -253,11 +288,16 @@ public sealed class QsoEditor columns.Add(new QsoColumn("QTC?", QsoField.MiscText, 6)); } columns.Add(new QsoColumn("Op", QsoField.Operator, 8)); + columns.Add(new QsoColumn("Cty", QsoField.CountryPrefix, 6)); + columns.Add(new QsoColumn("Pts", QsoField.Points, 4, IsEditable: false)); + columns.Add(new QsoColumn("Mult", QsoField.Multipliers, 5, IsEditable: false)); return columns; } private static QsoField FieldFor(ExchangeSlot slot) => slot switch { + ExchangeSlot.SentReport => QsoField.SentReport, + ExchangeSlot.SentNumber => QsoField.SentNumber, ExchangeSlot.ReceivedReport => QsoField.ReceivedReport, ExchangeSlot.SerialNumber => QsoField.ReceivedNumber, ExchangeSlot.Zone => QsoField.Zone, diff --git a/src/Nonemm.Session/QsoField.cs b/src/Nonemm.Session/QsoField.cs index f750b18..260d76e 100644 --- a/src/Nonemm.Session/QsoField.cs +++ b/src/Nonemm.Session/QsoField.cs @@ -1,7 +1,8 @@ namespace Nonemm.Session; -/// A field of a logged contact that can be edited in the log window. The names -/// match the log columns. +/// A field of a logged contact the log window can show. Most can be edited; +/// `Points` and `Multipliers` are worked out from the rules and are read only. +/// The names match the log columns. public enum QsoField { Time, @@ -29,4 +30,6 @@ public enum QsoField WpxPrefix, RadioNumber, RunPosition, + Points, + Multipliers, } diff --git a/tests/Nonemm.Core.Tests/SignalReportTests.cs b/tests/Nonemm.Core.Tests/SignalReportTests.cs new file mode 100644 index 0000000..abf13f1 --- /dev/null +++ b/tests/Nonemm.Core.Tests/SignalReportTests.cs @@ -0,0 +1,41 @@ +namespace Nonemm.Core.Tests; + +public class SignalReportTests +{ + [Theory] + [InlineData("599")] + [InlineData("59")] + [InlineData("339")] + [InlineData("111")] + public void AnRstReportIsTaken(string report) => + Assert.True(SignalReport.IsPlausible(report, Modes.Cw)); + + [Theory] + [InlineData("nice signal")] + [InlineData("5")] + [InlineData("5999")] + [InlineData("699")] + [InlineData("509")] + [InlineData("590")] + [InlineData("5N9")] + public void TextThatIsNotAnRstReportIsRefused(string report) => + Assert.False(SignalReport.IsPlausible(report, Modes.Cw)); + + /// FT8 and the other weak-signal modes exchange a signal-to-noise ratio. + [Theory] + [InlineData("-12", true)] + [InlineData("+03", true)] + [InlineData("0", true)] + [InlineData("599", false)] + [InlineData("-99", false)] + public void AWeakSignalModeReportsDecibels(string report, bool taken) => + Assert.Equal(taken, SignalReport.IsPlausible(report, Modes.Ft8)); + + [Fact] + public void RttyStillReportsRst() => + Assert.True(SignalReport.IsPlausible("599", Modes.Rtty)); + + [Fact] + public void ThePhoneExampleHasNoTone() => + Assert.Equal("59", SignalReport.Example(Modes.Usb)); +} diff --git a/tests/Nonemm.Session.Tests/QsoEditorTests.cs b/tests/Nonemm.Session.Tests/QsoEditorTests.cs index 75e684d..4b0d9b1 100644 --- a/tests/Nonemm.Session.Tests/QsoEditorTests.cs +++ b/tests/Nonemm.Session.Tests/QsoEditorTests.cs @@ -208,6 +208,57 @@ public class QsoEditorTests public void TheWorkingFrequencyCannotBeCleared() => Assert.False(CqWw().Apply(Contact(), QsoField.Frequency, "").IsAccepted); + [Fact] + public void ArbitraryTextInTheReportColumnIsRefused() + { + QsoEdit edit = CqWw().Apply(Contact(), QsoField.ReceivedReport, "good copy"); + + Assert.False(edit.IsAccepted); + Assert.Contains("signal report", edit.Error); + Assert.Equal(QsoField.ReceivedReport, edit.Field); + } + + [Fact] + public void TheSentReportIsCheckedTheSameWay() => + Assert.False(CqWw().Apply(Contact(), QsoField.SentReport, "699").IsAccepted); + + [Fact] + public void AReportCanStillBeCorrected() => + Assert.Equal("339", CqWw().Apply(Contact(), QsoField.ReceivedReport, "339").Result!.ReceivedReport); + + /// FT8 exchanges a signal-to-noise ratio, so RST is the wrong thing there + /// and decibels are the wrong thing on CW. + [Fact] + public void AWeakSignalContactReportsDecibels() + { + Qso ft8 = Contact() with { Mode = Modes.Ft8, ReceivedReport = "-12" }; + + Assert.True(CqWw().Apply(ft8, QsoField.ReceivedReport, "-07").IsAccepted); + Assert.False(CqWw().Apply(ft8, QsoField.ReceivedReport, "599").IsAccepted); + Assert.False(CqWw().Apply(Contact(), QsoField.ReceivedReport, "-07").IsAccepted); + } + + /// A WAE QTC row keeps the time sent and the other station's call in the + /// two report columns, so those are not reports to check. + [Fact] + public void TheReportColumnsOfAQtcRowAreLeftAlone() + { + QsoEditor editor = new(new Wae(ModeCategory.Cw), Me, CountryFile.Parse(Countries)); + Qso traffic = Contact() with { ContestName = "WAEDC", Exchange1 = "SQTC", MiscText = "3/10" }; + + Assert.True(editor.Apply(traffic, QsoField.SentReport, "1234").IsAccepted); + Assert.True(editor.Apply(traffic, QsoField.ReceivedReport, "DL1ABC").IsAccepted); + } + + [Fact] + public void TextThatIsNotALocatorIsRefused() => + Assert.False(CqWw().Apply(Contact(), QsoField.GridSquare, "nowhere").IsAccepted); + + /// A locator is stored the way it is written, whatever case it was typed in. + [Fact] + public void ALocatorIsStoredWithALowerCaseSubsquare() => + Assert.Equal("JN88dd", CqWw().Apply(Contact(), QsoField.GridSquare, "jn88dd").Result!.GridSquare); + [Fact] public void ACorrectedCountryPrefixIsKept() => Assert.Equal("DL", CqWw().Apply(Contact(), QsoField.CountryPrefix, "dl").Result!.CountryPrefix);