Count the log under any two of its fields

N1MM's statistics window. `StatisticsPivot` counts the contacts under one row
field and one column field, with the row total, the running total and the
column totals N1MM prints. The fields are N1MM's own two lists: 29 down the
rows, 20 of them across the columns, sorted the way N1MM's SQL sorts them, so
zone 10 comes after zone 2 and 80M before 20M.

The window has N1MM's two tabs. The table is the grid with `Tot` and `Accum`
and a `Total` line; the graph is a stacked column per row, one segment per
column, in one of N1MM's twelve palettes. "Include Dupes & X-QSOs" counts the
contacts left out of the claimed score and the ones marked as dupes.

Checked against a 3775-contact CQ WW SSB log: 3762 counted, which is what
`select count(*) ... where CLAIMEDQSO = 1 and ContactType <> 'D'` returns, and
the per-band totals match band by band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
2026-09-04 07:47:30 +00:00
co-authored by Claude Opus 5
parent 4eb8071d57
commit acd22a9b5e
15 changed files with 1145 additions and 1 deletions
+102
View File
@@ -0,0 +1,102 @@
using System.Globalization;
using Nonemm.Core;
namespace Nonemm.Session;
/// The fields N1MM's statistics window offers, in N1MM's own order. Every one
/// of them can go down the rows; nine of them cannot go across the columns,
/// because a column per call or per five minutes is wider than any screen.
public static class StatisticsFields
{
private static readonly string[] NotColumns =
[
"Hour", "CountryPrefix", "CK", "WPXPrefix", "MiscText",
"5 Minute", "10 Minute", "15 Minute", "Call",
];
public static readonly IReadOnlyList<StatisticsField> Rows =
[
new("Band", (q, _) => BandKey(q)),
Text("Operator", q => q.Operator),
new("Hour",
(q, _) => new PivotKey(
DayText(q),
q.TimestampUtc.ToString("HH", CultureInfo.InvariantCulture),
SecondOrder: q.TimestampUtc.Hour),
"Day",
"Hr"),
Number("RadioNr", q => q.RadioNumber),
Text("Mode", q => q.Mode.Name),
Text("CountryPrefix", q => q.CountryPrefix),
Number("Zone", q => q.Zone),
Text("Section", q => q.Section),
Number("CK", q => q.Check),
Text("Prec", q => q.Precedence),
Number("Points", q => q.Points),
Flag("Mult1", q => q.IsMultiplier1),
Flag("Mult2", q => q.IsMultiplier2),
Flag("Mult3", q => q.IsMultiplier3),
Text("WPXPrefix", q => q.WpxPrefix),
// N1MM counts a four-character field, so every station in a square
// lands in one row however precise a grid it sent
Text("GridSquare", q => q.GridSquare.Length > 4 ? q.GridSquare[..4] : q.GridSquare),
Number("Run1Run2", q => q.RunPosition),
Text("MiscText", q => q.MiscText),
Text("ContactType", q => q.ContactType),
Flag("IsRunQSO", q => q.IsRunQso),
new("Day", (q, _) => new PivotKey(DayText(q))),
Minutes(5),
Minutes(10),
Minutes(15),
new("Mode & Band",
(q, _) => new PivotKey(
q.Mode.Name,
q.Band?.Name ?? "",
SecondOrder: q.Band?.MegahertzLabel ?? 0),
"Mode",
"Band"),
Text("Exchange1", q => q.Exchange1),
Text("Call", q => q.Call.Text),
Text("RoverLocation", q => q.RoverLocation),
Text("Computer Name", q => q.StationName),
];
public static readonly IReadOnlyList<StatisticsField> Columns =
Rows.Where(f => !NotColumns.Contains(f.Name)).ToList();
/// The field of that name, or the first field when there is none: a name
/// read back out of the settings can be one this version no longer has.
public static StatisticsField Find(IReadOnlyList<StatisticsField> among, string name) =>
among.FirstOrDefault(f => f.Name == name) ?? among[0];
private static PivotKey BandKey(Qso qso) =>
new(qso.Band?.Name ?? "", Order: qso.Band?.MegahertzLabel ?? 0);
private static string DayText(Qso qso) =>
qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
private static StatisticsField Text(string name, Func<Qso, string> value) =>
new(name, (q, _) => new PivotKey(value(q)));
private static StatisticsField Number(string name, Func<Qso, int> value) =>
new(name, (q, _) =>
{
int found = value(q);
return new PivotKey(found.ToString(CultureInfo.InvariantCulture), Order: found);
});
/// N1MM reads the flag columns back as VB booleans, so the labels are
/// `True` and `False` and False sorts first.
private static StatisticsField Flag(string name, Func<Qso, bool> value) =>
new(name, (q, _) => value(q) ? new PivotKey("True", Order: 1) : new PivotKey("False"));
/// Minutes since the contest started, in steps of `span`. N1MM works this
/// out in SQLite with integer division, which cuts towards zero.
private static StatisticsField Minutes(int span) =>
new($"{span} Minute", (q, start) =>
{
long seconds = (long)(q.TimestampUtc - start).TotalSeconds;
long bucket = span * (seconds / (span * 60));
return new PivotKey(bucket.ToString(CultureInfo.InvariantCulture), Order: bucket);
});
}