From ac63987e0c5e2b83151f08692dbf65855756d165 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 28 Aug 2026 10:15:40 +0000 Subject: [PATCH] Move and batch spots the way N1MM does Two differences from N1MM in the spot path, both found by reading Packet.cs. Randomising moved every CW spot. PacketSpot.Randomize moves a spot only when it is not a dupe, not a self spot, not on a CQ frequency, is on CW and is not working split, and Packet skips it altogether for a station passed to another band. Moving a dupe or a split station makes it harder to find, not easier, so this now leaves the same ones alone. Whether a station has been worked comes from the log, so AppSession asks it before moving a spot. Split needed knowing where a station is listening, which is in the comment. Qsx reads it by N1MM's rules: QSX, UP, DOWN, DN, U or D, the word standing alone or after a space, a bare number after QSX meaning kilohertz inside the band and anything else an offset from the spot, an offset over a hundred kilohertz refused unless it says QSX, DN70 read as a grid square rather than five down, and a listening frequency outside the band thrown away. Spots reached the bandmap one at a time, each one redrawing every window that watches it. N1MM queues them and flushes the queue once a second, which is what this does now: the filters and the randomiser run over the batch, and the bandmap takes it in one call and raises one change. Co-Authored-By: Claude Opus 5 --- README.md | 8 ++- src/Nonemm.App/AppSession.cs | 65 ++++++++++++++++-- src/Nonemm.Spotting/Bandmap.cs | 21 ++++++ src/Nonemm.Spotting/Qsx.cs | 66 +++++++++++++++++++ src/Nonemm.Spotting/Spot.cs | 6 ++ src/Nonemm.Spotting/SpotJitter.cs | 18 +++-- src/Nonemm.Spotting/SpotLine.cs | 9 ++- tests/Nonemm.Spotting.Tests/QsxTests.cs | 60 +++++++++++++++++ .../Nonemm.Spotting.Tests/SpotJitterTests.cs | 33 ++++++++-- 9 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 src/Nonemm.Spotting/Qsx.cs create mode 100644 tests/Nonemm.Spotting.Tests/QsxTests.cs diff --git a/README.md b/README.md index 93c4253..999f816 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,13 @@ Three of the filters are worth spelling out: call, so `W3LPL` also covers `W3LPL-#`. Nothing is filtered by this. - **Randomised frequencies.** Each incoming CW spot is moved thirty or sixty hertz either way, which is what N1MM does, so the operator has to find the - station by ear. Phone and digital spots are left alone. + station by ear. Left where they are: phone and digital spots, stations already + worked, stations working split, your own spots, and stations passed to another + band — the same list N1MM leaves alone. + +Spots wait up to a second before they reach the bandmap, and then go on together. +N1MM collects them the same way: a skimmer feed sends more spots a minute than +any window can usefully redraw. The client speaks telnet properly: it answers the option negotiation instead of letting the control bytes turn up in the first lines of text, and it reads the diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index f91e7f0..f79d150 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -33,6 +33,13 @@ public sealed class AppSession : IDisposable private readonly Random jitter = new(); + /// Spots wait here until the next flush. N1MM collects them the same way, + /// because a skimmer feed sends more spots a minute than any window can + /// usefully redraw. + private readonly List arriving = []; + private readonly Lock arrivingGuard = new(); + private readonly Timer spotFlush; + public AppSession(UserPaths paths, Settings settings) { Paths = paths; @@ -45,8 +52,13 @@ public sealed class AppSession : IDisposable UserDefinedContestProblems = problems; BandPlan = settings.ToBandPlan(); ApplySpotSettings(); + spotFlush = new Timer(_ => FlushSpots(), null, SpotFlushInterval, SpotFlushInterval); } + /// How often the spots that have arrived go onto the bandmap. N1MM's own + /// queue is flushed once a second. + private static readonly TimeSpan SpotFlushInterval = TimeSpan.FromSeconds(1); + public UserPaths Paths { get; } public Settings Settings { get; private set; } @@ -466,17 +478,59 @@ public sealed class AppSession : IDisposable Changed?.Invoke(this, EventArgs.Empty); } - /// A spot the node sent, kept or dropped by the filters on the telnet - /// window. + /// A spot the node sent. It waits for the next flush rather than going + /// straight onto the bandmap. private void TakeSpot(Spot spot) { - if (!spotFilter.Accepts(spot)) + lock (arrivingGuard) { - return; + arriving.Add(spot); } - Bandmap.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter) : spot); } + /// The spots that arrived since the last flush, filtered and moved about, + /// put on the bandmap in one go. + private void FlushSpots() + { + List batch; + lock (arrivingGuard) + { + if (arriving.Count == 0) + { + return; + } + batch = [.. arriving]; + arriving.Clear(); + } + List keeping = []; + foreach (Spot spot in batch) + { + if (spotFilter.Accepts(spot)) + { + keeping.Add(Settings.RandomizeSpots + ? SpotJitter.Shifted(spot, BandPlan, jitter, IsDupe(spot)) + : spot); + } + } + if (keeping.Count > 0) + { + Bandmap.AddRange(keeping); + } + } + + /// Whether the station has already been worked, which decides whether a + /// spot is moved about: N1MM leaves a dupe where it is. + private bool IsDupe(Spot spot) => + Logging is not null && Logging.Log.Judge(new Qso + { + Id = "", + TimestampUtc = spot.AtUtc, + Call = spot.Call, + Frequency = spot.Frequency, + Mode = Position?.Mode ?? Modes.Cw, + ContestName = Logging.Contest.Name, + }) is { IsDupe: true }; + private void ApplySpotSettings() { spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History); @@ -499,6 +553,7 @@ public sealed class AppSession : IDisposable public void Dispose() { + spotFlush.Dispose(); DisposeRadios(); cluster?.Dispose(); network?.Dispose(); diff --git a/src/Nonemm.Spotting/Bandmap.cs b/src/Nonemm.Spotting/Bandmap.cs index 81163f9..710b1fa 100644 --- a/src/Nonemm.Spotting/Bandmap.cs +++ b/src/Nonemm.Spotting/Bandmap.cs @@ -43,6 +43,27 @@ public sealed class Bandmap Changed?.Invoke(this, EventArgs.Empty); } + /// A batch of spots in one go, so a busy node redraws the windows once + /// rather than once a spot. N1MM collects arriving spots the same way. + public void AddRange(IEnumerable spots) + { + int added = 0; + lock (guard) + { + foreach (Spot spot in spots) + { + string key = Key(spot); + this.spots[key] = spot; + arrived[key] = clock(); + added++; + } + } + if (added > 0) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + public void Remove(Callsign call, Band band) { string key = $"{call.Text}|{band.Name}"; diff --git a/src/Nonemm.Spotting/Qsx.cs b/src/Nonemm.Spotting/Qsx.cs new file mode 100644 index 0000000..bf3c279 --- /dev/null +++ b/src/Nonemm.Spotting/Qsx.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// The frequency a split station is listening on, read out of the spot's +/// comment. The rules are N1MM's `ParseQSX` and what `Packet` does with what it +/// returns. +/// +/// A spotter writes it as `QSX 14205`, `QSX 205`, `UP 2` or `DN 5`. The word +/// has to start the comment or follow a space, so a callsign or a grid square +/// that happens to hold the letters is not read as an offset. +public static class Qsx +{ + /// Anything further than this from the spot is not an offset: `UP 2` is two + /// kilohertz up, `UP 14205` is somebody writing a frequency after the wrong + /// word. + private const double FurthestOffsetKilohertz = 100; + + /// Zero when the comment says nothing about split working, or when what it + /// says lands outside the band. + public static Frequency Parse(string comment, Frequency spot) + { + string text = comment.ToUpperInvariant(); + double offset = Offset(text, "QSX") + ?? Offset(text, "UP") + ?? Negative(Offset(text, "DOWN")) + ?? Negative(Offset(text, "DN")) + ?? Offset(text, "U") + ?? Negative(Offset(text, "D")) + ?? 0; + bool named = text.Contains("QSX", StringComparison.Ordinal); + if (offset == 0 || (!named && Math.Abs(offset) > FurthestOffsetKilohertz)) + { + return Frequency.Zero; + } + + double kilohertz = named + // a bare number after QSX is kilohertz within the band + ? (offset < 1000 ? Math.Floor(spot.Kilohertz / 1000) * 1000 : 0) + offset + : spot.Kilohertz + offset; + Frequency qsx = Frequency.FromKilohertz(kilohertz); + return Bands.ForFrequency(spot) is { } band && band.Contains(qsx) ? qsx : Frequency.Zero; + } + + private static double? Negative(double? offset) => offset is null ? null : -offset; + + /// The number after the word, when the word stands on its own and a number + /// follows it. `DN70` is a grid square, not five kilohertz down. + private static double? Offset(string text, string word) + { + int at = text.IndexOf(word, StringComparison.Ordinal); + if (at < 0 || (at > 0 && text[at - 1] != ' ')) + { + return null; + } + string[] rest = text[(at + word.Length)..] + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (rest.Length == 0 + || !double.TryParse(rest[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + { + return null; + } + return Math.Abs(value); + } +} diff --git a/src/Nonemm.Spotting/Spot.cs b/src/Nonemm.Spotting/Spot.cs index 88badeb..ecee2d7 100644 --- a/src/Nonemm.Spotting/Spot.cs +++ b/src/Nonemm.Spotting/Spot.cs @@ -11,5 +11,11 @@ public sealed record Spot( string Spotter = "", string Comment = "") { + /// Where the station is listening when it is working split, read out of the + /// comment. Zero when it is not. + public Frequency Qsx { get; init; } + + public bool IsSplit => Qsx.Hertz > 0; + public Band? Band => Bands.ForFrequency(Frequency); } diff --git a/src/Nonemm.Spotting/SpotJitter.cs b/src/Nonemm.Spotting/SpotJitter.cs index 4b45e54..5cd8dde 100644 --- a/src/Nonemm.Spotting/SpotJitter.cs +++ b/src/Nonemm.Spotting/SpotJitter.cs @@ -10,13 +10,23 @@ public static class SpotJitter { private static readonly int[] Offsets = [-60, -30, 30, 60]; - /// Phone and digital spots are left alone: they are wide enough that thirty - /// hertz changes nothing. - public static Spot Shifted(Spot spot, BandPlan plan, Random random) => - plan.ModeAt(spot.Frequency) == ModeCategory.Cw + /// N1MM moves a spot only when it is on CW, is not a dupe, was not spotted + /// here, is not working split, and is not one of its own passed stations. + /// Everything else is left where it is: phone and digital are wide enough + /// that thirty hertz changes nothing, and moving a station somebody is + /// about to work makes it harder to find, not easier. + public static Spot Shifted(Spot spot, BandPlan plan, Random random, bool isDupe = false) + { + bool moves = spot.Source == SpotSource.Cluster + && !isDupe + && !spot.IsSplit + && plan.ModeAt(spot.Frequency) == ModeCategory.Cw + && !spot.Comment.Contains("Passed Station", StringComparison.OrdinalIgnoreCase); + return moves ? spot with { Frequency = Frequency.FromHertz(spot.Frequency.Hertz + Offsets[random.Next(Offsets.Length)]), } : spot; + } } diff --git a/src/Nonemm.Spotting/SpotLine.cs b/src/Nonemm.Spotting/SpotLine.cs index 63f5435..a281bf3 100644 --- a/src/Nonemm.Spotting/SpotLine.cs +++ b/src/Nonemm.Spotting/SpotLine.cs @@ -35,13 +35,18 @@ public static class SpotLine } string[] tail = fields[2..]; int stamp = LastTimeStamp(tail); + Frequency where = Frequency.FromKilohertz(kilohertz); + string comment = string.Join(' ', stamp < 0 ? tail : tail[..stamp]).Trim(); return new Spot( Callsign.Parse(fields[1]), - Frequency.FromKilohertz(kilohertz), + where, stamp < 0 ? nowUtc : TimeOf(tail[stamp], nowUtc), SpotSource.Cluster, spotter, - string.Join(' ', stamp < 0 ? tail : tail[..stamp]).Trim()); + comment) + { + Qsx = Qsx.Parse(comment, where), + }; } /// The node writes the spot's time as `1234Z`. It is usually the last word, diff --git a/tests/Nonemm.Spotting.Tests/QsxTests.cs b/tests/Nonemm.Spotting.Tests/QsxTests.cs new file mode 100644 index 0000000..1499fe7 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/QsxTests.cs @@ -0,0 +1,60 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting.Tests; + +public class QsxTests +{ + private static readonly Frequency Spot = Frequency.FromKilohertz(14_025); + + private static double Kilohertz(string comment) => Qsx.Parse(comment, Spot).Kilohertz; + + [Fact] + public void AWholeFrequencyAfterQsxIsTakenAsItIs() => + Assert.Equal(14_205, Kilohertz("QSX 14205")); + + [Fact] + public void ABareNumberAfterQsxIsKilohertzInTheBand() => + Assert.Equal(14_205, Kilohertz("QSX 205")); + + [Fact] + public void UpAndDownAreOffsetsFromTheSpot() + { + Assert.Equal(14_027, Kilohertz("UP 2")); + Assert.Equal(14_020, Kilohertz("DN 5")); + Assert.Equal(14_020, Kilohertz("DOWN 5")); + } + + [Fact] + public void ACommentThatSaysNothingAboutSplitGivesNothing() + { + Assert.Equal(0, Kilohertz("CQ TEST")); + Assert.Equal(0, Kilohertz("")); + } + + [Fact] + public void AGridSquareIsNotAnOffset() => + Assert.Equal(0, Kilohertz("TNX QSO DN70")); + + [Fact] + public void AWordThatOnlyHoldsTheLettersIsNotAnOffset() => + Assert.Equal(0, Kilohertz("GROUP 5")); + + [Fact] + public void ABigNumberAfterAnythingButQsxIsNotAnOffset() => + Assert.Equal(0, Kilohertz("UP 14205")); + + [Fact] + public void AListeningFrequencyOutsideTheBandIsDropped() => + Assert.Equal(0, Kilohertz("QSX 7205")); + + [Fact] + public void TheSpotLineCarriesTheListeningFrequency() + { + Spot? spot = SpotLine.Parse( + "DX de W3LPL: 14025.0 JA1XYZ UP 2 1234Z", + new DateTime(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc)); + + Assert.Equal(14_027_000, Assert.IsType(spot).Qsx.Hertz); + Assert.True(spot.IsSplit); + } +} diff --git a/tests/Nonemm.Spotting.Tests/SpotJitterTests.cs b/tests/Nonemm.Spotting.Tests/SpotJitterTests.cs index ce946c3..a5f41b1 100644 --- a/tests/Nonemm.Spotting.Tests/SpotJitterTests.cs +++ b/tests/Nonemm.Spotting.Tests/SpotJitterTests.cs @@ -4,8 +4,12 @@ namespace Nonemm.Spotting.Tests; public class SpotJitterTests { - private static Spot At(double kilohertz) => - new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, SpotSource.Cluster); + private static Spot At(double kilohertz, SpotSource source = SpotSource.Cluster, string comment = "") => + new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, source, "W3LPL", comment); + + private static long Offset(Spot spot, bool isDupe = false) => + SpotJitter.Shifted(spot, BandPlan.Default, new Random(1), isDupe).Frequency.Hertz + - spot.Frequency.Hertz; [Fact] public void ACwSpotMovesByThirtyOrSixtyHertz() @@ -14,15 +18,30 @@ public class SpotJitterTests for (int run = 0; run < 20; run++) { Spot moved = SpotJitter.Shifted(At(14_025), BandPlan.Default, random); - long offset = moved.Frequency.Hertz - 14_025_000; - Assert.Contains(offset, (long[])[-60, -30, 30, 60]); + Assert.Contains(moved.Frequency.Hertz - 14_025_000, (long[])[-60, -30, 30, 60]); } } [Fact] - public void APhoneSpotIsLeftWhereItIs() + public void APhoneSpotIsLeftWhereItIs() => Assert.Equal(0, Offset(At(14_250))); + + [Fact] + public void AStationAlreadyWorkedIsLeftWhereItIs() => + Assert.Equal(0, Offset(At(14_025), isDupe: true)); + + [Fact] + public void AStationWorkingSplitIsLeftWhereItIs() { - Spot spot = At(14_250); - Assert.Equal(spot, SpotJitter.Shifted(spot, BandPlan.Default, new Random(1))); + Spot split = At(14_025, comment: "UP 2") with { Qsx = Frequency.FromKilohertz(14_027) }; + + Assert.Equal(0, Offset(split)); } + + [Fact] + public void OurOwnSpotIsLeftWhereItIs() => + Assert.Equal(0, Offset(At(14_025, SpotSource.Operator))); + + [Fact] + public void AStationPassedToAnotherBandIsLeftWhereItIs() => + Assert.Equal(0, Offset(At(14_025, comment: "Passed Station 21025"))); }