Files
Nonemm/src/Nonemm.Contests/ContestLog.cs
T
erikbrockoandClaude Sonnet 5.5 aa54f8dcbe Keep the Check window's lists and columns between key presses
Tracing key presses with a busy feed showed the Check window rebuilding
the distinct call lists of the log, the call history and every spot,
and all five columns of controls, on each press.

- ContestLog.Revision goes up with every change to the contacts. The
  Check sources keep each call list until its source changes.
- The Check window builds a column again only when what it shows
  differs.

UI thread time while typing with eight windows open went from 14.6% to
9.3%.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 13:42:37 +00:00

389 lines
14 KiB
C#

using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
namespace Nonemm.Contests;
/// The contacts of one contest, with the indexes that answer "worked before?"
/// and "new multiplier?" while the operator types.
public sealed class ContestLog
{
private readonly Contest contest;
private readonly StationInfo me;
private readonly CountryFile? countries;
private readonly List<Qso> qsos = [];
private readonly Dictionary<string, DateTime> workedKeys = new(StringComparer.Ordinal);
/// How many QTC lines have gone with each station, either way. It is kept
/// here rather than counted when a station is judged, because the bandmap
/// judges every spot it holds on every pass.
private readonly Dictionary<string, int> trafficWith = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
private ScoreTally tally = new();
/// `Judge` is called from a background thread by the windows that judge
/// every spot, while the UI thread adds and edits contacts. Both take this
/// lock around the indexes. The lock can be entered again by the thread
/// that holds it, because a rebuild judges each contact.
private readonly Lock guard = new();
public ContestLog(
Contest contest,
StationInfo me,
CountryFile? countries,
ContestEntry? entry = null)
{
this.contest = contest;
this.me = me;
this.countries = countries;
Entry = entry ?? new ContestEntry();
}
/// The categories and sent exchange of the running entry. The operator can
/// change them while the contest is open, and the score follows.
public ContestEntry Entry { get; set; }
/// The call history file, for a contest that counts a multiplier from it.
/// The session sets it when the operator loads one.
public CallHistory History { get; set; } = CallHistory.Empty;
public Contest Contest => contest;
/// Goes up with every change to the contacts, so something worked out
/// from the log can be kept until this changes.
public int Revision { get; private set; }
public IReadOnlyList<Qso> Qsos => qsos;
/// The contacts alone. In WAE the log also holds QTC traffic, and a QTC
/// row is not a contact: its report columns hold the time and callsign of
/// the contact being reported. Anything that writes contacts out — the
/// ADIF export — takes these rather than every row.
public IEnumerable<Qso> Contacts => qsos.Where(contest.IsContact);
public ScoreTally Tally => tally;
/// The same score split the way N1MM's score window splits it: a row per
/// band, and per kind of row as well where the contest has kinds. The
/// rows add up to `Tally`.
public IReadOnlyDictionary<ScoreKey, ScoreTally> Rows => rows;
public int TotalScore => contest.TotalScore(tally, Entry);
/// How many QTC lines have already gone with this station, either way.
public int TrafficWith(string call) =>
trafficWith.TryGetValue(call.Trim(), out int count) ? count : 0;
/// What logging this contact right now would do, without logging it.
public Verdict Judge(Qso candidate)
{
lock (guard)
{
return JudgeLocked(candidate);
}
}
private Verdict JudgeLocked(Qso candidate)
{
if (!candidate.IsClaimed)
{
return new Verdict(false, 0, []);
}
QsoContext context = ContextFor(candidate);
if (!contest.IsContact(candidate))
{
return new Verdict(false, contest.PointsFor(context), []);
}
int trafficLeft = contest.TrafficLeft(context, TrafficWith(candidate.Call.Text));
if (IsWorked(candidate))
{
return Verdict.Dupe with { TrafficLeft = trafficLeft };
}
List<Multiplier> newOnes = [];
foreach (Multiplier multiplier in contest.MultipliersFor(context))
{
if (!claimedMultipliers.Contains(MultiplierKey(multiplier, candidate)))
{
newOnes.Add(multiplier);
}
}
// a contest can pay a bonus for a new multiplier, so the flags have to
// be set before the points are worked out
QsoContext scored = ContextFor(ApplyMultiplierFlags(candidate, newOnes));
return new Verdict(
false,
IsContestMode(candidate) ? contest.PointsFor(scored) : 0,
newOnes,
trafficLeft);
}
/// Adds the contact and returns it with points and multiplier flags filled in.
public Qso Add(Qso qso)
{
lock (guard)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
Revision++;
return scored;
}
}
/// Loads contacts read back from storage. Points and multiplier flags in
/// the stored rows are ignored and worked out again from the rules.
public void Restore(IEnumerable<Qso> stored)
{
lock (guard)
{
qsos.AddRange(stored);
Rebuild();
}
}
/// Adds a contact that may be older than ones already logged, which is what
/// a contact from another station of the entry can be, and scores the log
/// again: the earlier of two contacts is the one that claims a multiplier.
public void Insert(Qso qso)
{
lock (guard)
{
qsos.Add(qso);
Rebuild();
}
}
/// `Insert` for several contacts, scoring the log once instead of once per
/// contact. A resync sends a few hundred in a burst.
public void InsertRange(IEnumerable<Qso> arrived)
{
lock (guard)
{
qsos.AddRange(arrived);
Rebuild();
}
}
public void Remove(string id)
{
lock (guard)
{
qsos.RemoveAll(q => q.Id == id);
Rebuild();
}
}
/// `Remove` for several contacts, scoring the log once.
public void RemoveRange(IReadOnlyCollection<string> ids)
{
HashSet<string> gone = [.. ids];
lock (guard)
{
qsos.RemoveAll(q => gone.Contains(q.Id));
Rebuild();
}
}
public void Replace(Qso qso)
{
lock (guard)
{
int at = qsos.FindIndex(q => q.Id == qso.Id);
if (at < 0)
{
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
}
qsos[at] = qso;
Rebuild();
}
}
/// A station worked before, under the contest's dupe rule. A contest with
/// a dupe window — N1MM's `DupeQSOMinutesAgo` — lets the same station be
/// worked again once the window has passed.
public bool IsWorked(Qso candidate)
{
if (contest.DupeScope == DupeScope.Never || !contest.IsContact(candidate))
{
return false;
}
if (!workedKeys.TryGetValue(DupeKey(candidate), out DateTime last))
{
return false;
}
return contest.DupeWindow is not { } window
|| (candidate.TimestampUtc - last).Duration() < window;
}
/// Every contact with this call, newest first.
public IReadOnlyList<Qso> WorkedBefore(string call) =>
qsos.Where(q => string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(q => q.TimestampUtc)
.ToList();
/// A contact in a mode the contest does not run — a tuning carrier logged
/// by accident, or a CW contact in an SSB contest — scores nothing. It
/// still brings its multiplier, which is what N1MM logs. A contest with no
/// mode of its own takes every mode.
private bool IsContestMode(Qso qso) =>
contest.Modes.Count == 0 || contest.Modes.Contains(qso.Mode.Category);
/// A maritime or aeronautical mobile belongs to no country, and the
/// contest still has to know which continent it is working, so the call it
/// is signing from is looked up when the call as sent places nowhere. What
/// that country counts for is the contest's decision.
private QsoContext ContextFor(Qso qso) =>
new(qso, countries?.Find(qso.Call) ?? countries?.Find(qso.Call.Station), me, Entry)
{
History = History,
};
private Qso ApplyVerdict(Qso qso, Verdict verdict) =>
ApplyMultiplierFlags(qso, verdict.NewMultipliers) with
{
Points = verdict.Points,
ContactType = ContactTypeOf(qso),
};
/// N1MM's contact type column, which it writes in `Contact.IsDupe`: `D`
/// for a station already worked, `N` for one this entry may not work at
/// all, and empty for an ordinary contact. Every contest is marked the
/// same way; what changes between them is what counts as a dupe and which
/// stations are workable, and both of those come from the contest here as
/// they do there.
///
/// Traffic keeps an empty column, which is what N1MM's QTC window writes:
/// its `IsDupe` only marks a row while the program is logging contacts.
private string ContactTypeOf(Qso qso)
{
if (!contest.IsContact(qso))
{
return "";
}
// a mark that came with the contact stands: N1MM marks a contact when
// it logs or edits it and never clears the mark in a pass over the
// log, so a row that arrived from another station or from an N1MM
// database keeps what it was given
if (IsWorked(qso) || qso.ContactType.Equals("D", StringComparison.OrdinalIgnoreCase))
{
return "D";
}
// N1MM adds our own callsign to whatever the contest says
bool workable = contest.IsWorkable(ContextFor(qso))
&& !qso.Call.Text.Equals(me.Callsign, StringComparison.OrdinalIgnoreCase);
return workable ? "" : "N";
}
private static Qso ApplyMultiplierFlags(Qso qso, IReadOnlyList<Multiplier> claimed) => qso with
{
IsMultiplier1 = claimed.Any(m => m.Index == 1),
IsMultiplier2 = claimed.Any(m => m.Index == 2),
IsMultiplier3 = claimed.Any(m => m.Index == 3),
};
/// Adds the contact to the score and to the indexes that answer "worked
/// before?" and "new multiplier?". An unclaimed contact enters neither:
/// N1MM scores it as zero and leaves it out of both searches, so it takes
/// nothing away from a later contact with the same call or multiplier. A
/// contact marked as a dupe is the other way round — N1MM searches for
/// dupes and multipliers among it, and leaves it out of the score alone.
private void Index(Qso qso, Verdict verdict)
{
if (!qso.IsClaimed)
{
return;
}
bool counts = qso.IsInScore;
ScoreTally row = RowFor(qso);
if (counts)
{
tally.AddQso(verdict.Points);
row.AddQso(verdict.Points);
}
if (!contest.IsContact(qso))
{
string station = qso.Call.Text.Trim();
trafficWith[station] = trafficWith.GetValueOrDefault(station) + 1;
return;
}
workedKeys[DupeKey(qso)] = qso.TimestampUtc;
foreach (Multiplier multiplier in verdict.NewMultipliers)
{
claimedMultipliers.Add(MultiplierKey(multiplier, qso));
if (counts)
{
tally.AddMultiplier(multiplier);
row.AddMultiplier(multiplier);
}
}
}
/// Scores the whole log again. Nothing here needs it — every change
/// rescores what it touches — but the stored points and multiplier flags
/// are written from this, and a caller that is about to write them asks for
/// a pass first.
public void Rescore()
{
lock (guard)
{
Rebuild();
}
}
/// Rescores the whole log. A removed or edited contact can hand its
/// multiplier to a later one, which only a full pass gets right.
private ScoreTally RowFor(Qso qso)
{
ScoreKey key = new(qso.Band?.Name ?? "", contest.RowKindFor(qso));
if (!rows.TryGetValue(key, out ScoreTally? row))
{
row = new ScoreTally();
rows[key] = row;
}
return row;
}
private void Rebuild()
{
Revision++;
workedKeys.Clear();
trafficWith.Clear();
claimedMultipliers.Clear();
rows.Clear();
tally = new ScoreTally();
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
qsos.Clear();
foreach (Qso qso in ordered)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
}
}
private string DupeKey(Qso qso)
{
string call = qso.Call.Text.ToUpperInvariant();
string extra = contest.ExtraDupeKey(qso);
string key = contest.DupeScope switch
{
DupeScope.Once => call,
DupeScope.PerBand => $"{call}|{qso.Band?.Name}",
DupeScope.PerMode => $"{call}|{qso.Mode.Category}",
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
_ => call,
};
// a station worked in an earlier session may be worked again
key = $"{key}|{contest.SessionOf(qso.TimestampUtc)}";
return extra.Length == 0 ? key : $"{key}|{extra}";
}
private string MultiplierKey(Multiplier multiplier, Qso qso) =>
contest.ResetsMultipliersEachSession
? $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}|{contest.SessionOf(qso.TimestampUtc)}"
: $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
}