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 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:15:40 +00:00
parent f102e0a304
commit ac63987e0c
9 changed files with 267 additions and 19 deletions

View File

@@ -496,7 +496,13 @@ Three of the filters are worth spelling out:
call, so `W3LPL` also covers `W3LPL-#`. Nothing is filtered by this. call, so `W3LPL` also covers `W3LPL-#`. Nothing is filtered by this.
- **Randomised frequencies.** Each incoming CW spot is moved thirty or sixty - **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 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 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 letting the control bytes turn up in the first lines of text, and it reads the

View File

@@ -33,6 +33,13 @@ public sealed class AppSession : IDisposable
private readonly Random jitter = new(); 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<Spot> arriving = [];
private readonly Lock arrivingGuard = new();
private readonly Timer spotFlush;
public AppSession(UserPaths paths, Settings settings) public AppSession(UserPaths paths, Settings settings)
{ {
Paths = paths; Paths = paths;
@@ -45,8 +52,13 @@ public sealed class AppSession : IDisposable
UserDefinedContestProblems = problems; UserDefinedContestProblems = problems;
BandPlan = settings.ToBandPlan(); BandPlan = settings.ToBandPlan();
ApplySpotSettings(); 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 UserPaths Paths { get; }
public Settings Settings { get; private set; } public Settings Settings { get; private set; }
@@ -466,16 +478,58 @@ public sealed class AppSession : IDisposable
Changed?.Invoke(this, EventArgs.Empty); Changed?.Invoke(this, EventArgs.Empty);
} }
/// A spot the node sent, kept or dropped by the filters on the telnet /// A spot the node sent. It waits for the next flush rather than going
/// window. /// straight onto the bandmap.
private void TakeSpot(Spot spot) private void TakeSpot(Spot spot)
{ {
if (!spotFilter.Accepts(spot)) lock (arrivingGuard)
{
arriving.Add(spot);
}
}
/// The spots that arrived since the last flush, filtered and moved about,
/// put on the bandmap in one go.
private void FlushSpots()
{
List<Spot> batch;
lock (arrivingGuard)
{
if (arriving.Count == 0)
{ {
return; return;
} }
Bandmap.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter) : spot); batch = [.. arriving];
arriving.Clear();
} }
List<Spot> 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() private void ApplySpotSettings()
{ {
@@ -499,6 +553,7 @@ public sealed class AppSession : IDisposable
public void Dispose() public void Dispose()
{ {
spotFlush.Dispose();
DisposeRadios(); DisposeRadios();
cluster?.Dispose(); cluster?.Dispose();
network?.Dispose(); network?.Dispose();

View File

@@ -43,6 +43,27 @@ public sealed class Bandmap
Changed?.Invoke(this, EventArgs.Empty); 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<Spot> 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) public void Remove(Callsign call, Band band)
{ {
string key = $"{call.Text}|{band.Name}"; string key = $"{call.Text}|{band.Name}";

View File

@@ -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);
}
}

View File

@@ -11,5 +11,11 @@ public sealed record Spot(
string Spotter = "", string Spotter = "",
string Comment = "") 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); public Band? Band => Bands.ForFrequency(Frequency);
} }

View File

@@ -10,13 +10,23 @@ public static class SpotJitter
{ {
private static readonly int[] Offsets = [-60, -30, 30, 60]; private static readonly int[] Offsets = [-60, -30, 30, 60];
/// Phone and digital spots are left alone: they are wide enough that thirty /// N1MM moves a spot only when it is on CW, is not a dupe, was not spotted
/// hertz changes nothing. /// here, is not working split, and is not one of its own passed stations.
public static Spot Shifted(Spot spot, BandPlan plan, Random random) => /// Everything else is left where it is: phone and digital are wide enough
plan.ModeAt(spot.Frequency) == ModeCategory.Cw /// 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 ? spot with
{ {
Frequency = Frequency.FromHertz(spot.Frequency.Hertz + Offsets[random.Next(Offsets.Length)]), Frequency = Frequency.FromHertz(spot.Frequency.Hertz + Offsets[random.Next(Offsets.Length)]),
} }
: spot; : spot;
} }
}

View File

@@ -35,13 +35,18 @@ public static class SpotLine
} }
string[] tail = fields[2..]; string[] tail = fields[2..];
int stamp = LastTimeStamp(tail); int stamp = LastTimeStamp(tail);
Frequency where = Frequency.FromKilohertz(kilohertz);
string comment = string.Join(' ', stamp < 0 ? tail : tail[..stamp]).Trim();
return new Spot( return new Spot(
Callsign.Parse(fields[1]), Callsign.Parse(fields[1]),
Frequency.FromKilohertz(kilohertz), where,
stamp < 0 ? nowUtc : TimeOf(tail[stamp], nowUtc), stamp < 0 ? nowUtc : TimeOf(tail[stamp], nowUtc),
SpotSource.Cluster, SpotSource.Cluster,
spotter, 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, /// The node writes the spot's time as `1234Z`. It is usually the last word,

View File

@@ -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>(spot).Qsx.Hertz);
Assert.True(spot.IsSplit);
}
}

View File

@@ -4,8 +4,12 @@ namespace Nonemm.Spotting.Tests;
public class SpotJitterTests public class SpotJitterTests
{ {
private static Spot At(double kilohertz) => private static Spot At(double kilohertz, SpotSource source = SpotSource.Cluster, string comment = "") =>
new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, SpotSource.Cluster); 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] [Fact]
public void ACwSpotMovesByThirtyOrSixtyHertz() public void ACwSpotMovesByThirtyOrSixtyHertz()
@@ -14,15 +18,30 @@ public class SpotJitterTests
for (int run = 0; run < 20; run++) for (int run = 0; run < 20; run++)
{ {
Spot moved = SpotJitter.Shifted(At(14_025), BandPlan.Default, random); Spot moved = SpotJitter.Shifted(At(14_025), BandPlan.Default, random);
long offset = moved.Frequency.Hertz - 14_025_000; Assert.Contains(moved.Frequency.Hertz - 14_025_000, (long[])[-60, -30, 30, 60]);
Assert.Contains(offset, (long[])[-60, -30, 30, 60]);
} }
} }
[Fact] [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); Spot split = At(14_025, comment: "UP 2") with { Qsx = Frequency.FromKilohertz(14_027) };
Assert.Equal(spot, SpotJitter.Shifted(spot, BandPlan.Default, new Random(1)));
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")));
} }