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:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
|||||||
66
src/Nonemm.Spotting/Qsx.cs
Normal file
66
src/Nonemm.Spotting/Qsx.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
60
tests/Nonemm.Spotting.Tests/QsxTests.cs
Normal file
60
tests/Nonemm.Spotting.Tests/QsxTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user