Found by tracing the app with a fake skimmer feed, a fake rigctld and eight tool windows open (about 3,500 spots on the bandmap). - Drop expired spots from the one-second spot timer instead of from each window's refresh on the UI thread. - Bandmap keeps the lists On and All return until the spots change, and Near searches only the band the frequency is in. - The Available window replaces its rows at most every two seconds, the Score window redraws at most once a second, and the Telnet list takes new lines four times a second at most (RateLimited). - The bandmap leader line pen is made once. Idle CPU in that setup went from about 33-70% of one core to about 16-25%. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
197 lines
6.1 KiB
C#
197 lines
6.1 KiB
C#
using Nonemm.Core;
|
|
|
|
namespace Nonemm.Spotting;
|
|
|
|
/// The stations on the band, in frequency order. A spot ages out after the
|
|
/// timeout because a bandmap is a picture of the last hour or so, and a stale
|
|
/// spot costs a move to an empty frequency.
|
|
///
|
|
/// Age is counted from when the spot arrived, not from the time written in it.
|
|
/// A node with a wrong clock, or one replaying its backlog, would otherwise
|
|
/// empty the bandmap as fast as it filled it.
|
|
///
|
|
/// Spots arrive on the cluster's thread and are read on the window's, so every
|
|
/// method locks. The `Changed` event is raised outside the lock, on whichever
|
|
/// thread made the change.
|
|
public sealed class Bandmap
|
|
{
|
|
private readonly Dictionary<string, Spot> spots = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, DateTime> arrived = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Lock guard = new();
|
|
private readonly Func<DateTime> clock;
|
|
|
|
/// The lists `On` and `All` hand out, kept until the next change. The
|
|
/// windows ask on every refresh, a refresh can come many times a second
|
|
/// while the spots change once a second, and each answer is a scan and a
|
|
/// sort of every spot.
|
|
private readonly Dictionary<Band, IReadOnlyList<Spot>> onBand = [];
|
|
private IReadOnlyList<Spot>? everything;
|
|
|
|
public Bandmap(TimeSpan? lifetime = null, Func<DateTime>? clock = null)
|
|
{
|
|
Lifetime = lifetime ?? TimeSpan.FromHours(1);
|
|
this.clock = clock ?? (() => DateTime.UtcNow);
|
|
}
|
|
|
|
/// How long a spot stays on the map when `LifetimeFor` is not set.
|
|
public TimeSpan Lifetime { get; set; }
|
|
|
|
/// How long a given spot stays on the map, for a different time in each
|
|
/// mode. N1MM asks for one number in minutes, on the telnet window's
|
|
/// Filters tab.
|
|
public Func<Spot, TimeSpan>? LifetimeFor { get; set; }
|
|
|
|
public event EventHandler? Changed;
|
|
|
|
public void Add(Spot spot)
|
|
{
|
|
string key = Key(spot);
|
|
lock (guard)
|
|
{
|
|
spots[key] = spot;
|
|
arrived[key] = clock();
|
|
Forget();
|
|
}
|
|
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++;
|
|
}
|
|
Forget();
|
|
}
|
|
if (added > 0)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
public void Remove(Callsign call, Band band)
|
|
{
|
|
string key = $"{call.Text}|{band.Name}";
|
|
bool removed;
|
|
lock (guard)
|
|
{
|
|
arrived.Remove(key);
|
|
removed = spots.Remove(key);
|
|
Forget();
|
|
}
|
|
if (removed)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
public void DropOlderThan(DateTime nowUtc)
|
|
{
|
|
int dropped;
|
|
lock (guard)
|
|
{
|
|
List<string> stale = spots
|
|
.Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc)
|
|
> (LifetimeFor?.Invoke(pair.Value) ?? Lifetime))
|
|
.Select(pair => pair.Key)
|
|
.ToList();
|
|
foreach (string key in stale)
|
|
{
|
|
spots.Remove(key);
|
|
arrived.Remove(key);
|
|
}
|
|
dropped = stale.Count;
|
|
if (dropped > 0)
|
|
{
|
|
Forget();
|
|
}
|
|
}
|
|
if (dropped > 0)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
/// How long the spot has left before it drops off the map, or null for a
|
|
/// spot that is not on it.
|
|
public TimeSpan? Remaining(Spot spot)
|
|
{
|
|
string key = Key(spot);
|
|
lock (guard)
|
|
{
|
|
return arrived.TryGetValue(key, out DateTime at)
|
|
? (LifetimeFor?.Invoke(spot) ?? Lifetime) - (clock() - at)
|
|
: null;
|
|
}
|
|
}
|
|
|
|
/// Called under the lock by everything that changes the spots.
|
|
private void Forget()
|
|
{
|
|
onBand.Clear();
|
|
everything = null;
|
|
}
|
|
|
|
public IReadOnlyList<Spot> On(Band band)
|
|
{
|
|
lock (guard)
|
|
{
|
|
if (!onBand.TryGetValue(band, out IReadOnlyList<Spot>? found))
|
|
{
|
|
found = spots.Values
|
|
.Where(s => s.Band == band)
|
|
.OrderBy(s => s.Frequency.Hertz)
|
|
.ToList();
|
|
onBand[band] = found;
|
|
}
|
|
return found;
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<Spot> All()
|
|
{
|
|
lock (guard)
|
|
{
|
|
return everything ??= spots.Values.OrderBy(s => s.Frequency.Hertz).ToList();
|
|
}
|
|
}
|
|
|
|
/// The spot nearest the frequency, within the window either side. Used when
|
|
/// the radio lands on a spot so the entry window can fill the call in.
|
|
public Spot? Near(Frequency frequency, Frequency window)
|
|
{
|
|
lock (guard)
|
|
{
|
|
Spot? best = null;
|
|
long bestDistance = long.MaxValue;
|
|
// only the band the frequency is in is searched, which is a few
|
|
// hundred spots rather than all of them
|
|
IEnumerable<Spot> candidates = Bands.ForFrequency(frequency) is { } band
|
|
? On(band)
|
|
: spots.Values;
|
|
foreach (Spot spot in candidates)
|
|
{
|
|
long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
|
|
if (distance <= window.Hertz && distance < bestDistance)
|
|
{
|
|
best = spot;
|
|
bestDistance = distance;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
}
|
|
|
|
/// One spot per station per band: a station that moves within the band keeps
|
|
/// one entry rather than leaving a trail.
|
|
private static string Key(Spot spot) => $"{spot.Call.Text}|{spot.Band?.Name}";
|
|
}
|