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

@@ -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<Spot> 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<Spot> batch;
lock (arrivingGuard)
{
if (arriving.Count == 0)
{
return;
}
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()
{
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();

View File

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

View File

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

View File

@@ -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,