Files
Nonemm/src/Nonemm.Spotting/Bandmap.cs
T
erikbrockoandClaude Sonnet 5.5 5f9d186150 Cut idle CPU with a busy cluster feed and many windows open
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>
2026-10-25 13:34:50 +00:00

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