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:
@@ -0,0 +1,33 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// One row or column of the statistics table: what is printed, and what it
|
||||
/// sorts by. `Second` is empty except for the two fields that print two label
|
||||
/// columns, Hour (day and hour) and Mode & Band.
|
||||
///
|
||||
/// N1MM sorts on the SQL expression rather than on the text, so a numeric field
|
||||
/// runs 1, 2, 10 and not 1, 10, 2. `Order` carries that number; a text field
|
||||
/// leaves it at zero and sorts on the text.
|
||||
public readonly record struct PivotKey(
|
||||
string First,
|
||||
string Second = "",
|
||||
double Order = 0,
|
||||
double SecondOrder = 0) : IComparable<PivotKey>
|
||||
{
|
||||
public string Label => Second.Length == 0 ? First : First + " " + Second;
|
||||
|
||||
public int CompareTo(PivotKey other)
|
||||
{
|
||||
int by = Order.CompareTo(other.Order);
|
||||
if (by != 0)
|
||||
{
|
||||
return by;
|
||||
}
|
||||
by = string.CompareOrdinal(First, other.First);
|
||||
if (by != 0)
|
||||
{
|
||||
return by;
|
||||
}
|
||||
by = SecondOrder.CompareTo(other.SecondOrder);
|
||||
return by != 0 ? by : string.CompareOrdinal(Second, other.Second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// One field the statistics window can put down the rows or across the columns.
|
||||
/// `Headings` holds two entries for the fields that print two label columns.
|
||||
public sealed class StatisticsField
|
||||
{
|
||||
private readonly Func<Qso, DateTime, PivotKey> read;
|
||||
|
||||
internal StatisticsField(string name, Func<Qso, DateTime, PivotKey> read, params string[] headings)
|
||||
{
|
||||
Name = name;
|
||||
this.read = read;
|
||||
Headings = headings.Length > 0 ? headings : [name];
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public IReadOnlyList<string> Headings { get; }
|
||||
|
||||
/// `startUtc` is when the contest started: the five, ten and fifteen minute
|
||||
/// fields count from it.
|
||||
public PivotKey KeyOf(Qso qso, DateTime startUtc) => read(qso, startUtc);
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// N1MM's statistics table: how many contacts fall under each pair of values of
|
||||
/// the two fields chosen, with the running total down the right and the column
|
||||
/// totals along the bottom.
|
||||
public sealed class StatisticsPivot
|
||||
{
|
||||
private readonly int[,] counts;
|
||||
|
||||
private StatisticsPivot(
|
||||
StatisticsField row,
|
||||
StatisticsField column,
|
||||
IReadOnlyList<PivotKey> rows,
|
||||
IReadOnlyList<PivotKey> columns,
|
||||
int[,] counts)
|
||||
{
|
||||
Row = row;
|
||||
Column = column;
|
||||
Rows = rows;
|
||||
Columns = columns;
|
||||
this.counts = counts;
|
||||
|
||||
int[] rowTotals = new int[rows.Count];
|
||||
int[] accumulated = new int[rows.Count];
|
||||
int[] columnTotals = new int[columns.Count];
|
||||
int running = 0;
|
||||
for (int down = 0; down < rows.Count; down++)
|
||||
{
|
||||
for (int across = 0; across < columns.Count; across++)
|
||||
{
|
||||
rowTotals[down] += counts[down, across];
|
||||
columnTotals[across] += counts[down, across];
|
||||
}
|
||||
running += rowTotals[down];
|
||||
accumulated[down] = running;
|
||||
}
|
||||
RowTotals = rowTotals;
|
||||
Accumulated = accumulated;
|
||||
ColumnTotals = columnTotals;
|
||||
Total = running;
|
||||
}
|
||||
|
||||
/// `includeDupes` is N1MM's "Include Dupes & X-QSOs": with it clear, a
|
||||
/// contact left out of the claimed score and a contact marked as a dupe are
|
||||
/// both left out of the count.
|
||||
public static StatisticsPivot Of(
|
||||
IReadOnlyList<Qso> qsos,
|
||||
StatisticsField row,
|
||||
StatisticsField column,
|
||||
DateTime startUtc,
|
||||
bool includeDupes = false)
|
||||
{
|
||||
Dictionary<(PivotKey Down, PivotKey Across), int> cells = [];
|
||||
SortedSet<PivotKey> rows = [];
|
||||
SortedSet<PivotKey> columns = [];
|
||||
foreach (Qso qso in qsos)
|
||||
{
|
||||
if (!includeDupes && !IsCounted(qso))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
PivotKey down = row.KeyOf(qso, startUtc);
|
||||
PivotKey across = column.KeyOf(qso, startUtc);
|
||||
rows.Add(down);
|
||||
columns.Add(across);
|
||||
cells[(down, across)] = cells.GetValueOrDefault((down, across)) + 1;
|
||||
}
|
||||
|
||||
List<PivotKey> rowKeys = [.. rows];
|
||||
List<PivotKey> columnKeys = [.. columns];
|
||||
int[,] counts = new int[rowKeys.Count, columnKeys.Count];
|
||||
for (int down = 0; down < rowKeys.Count; down++)
|
||||
{
|
||||
for (int across = 0; across < columnKeys.Count; across++)
|
||||
{
|
||||
counts[down, across] = cells.GetValueOrDefault((rowKeys[down], columnKeys[across]));
|
||||
}
|
||||
}
|
||||
return new StatisticsPivot(row, column, rowKeys, columnKeys, counts);
|
||||
}
|
||||
|
||||
public StatisticsField Row { get; }
|
||||
|
||||
public StatisticsField Column { get; }
|
||||
|
||||
public IReadOnlyList<PivotKey> Rows { get; }
|
||||
|
||||
public IReadOnlyList<PivotKey> Columns { get; }
|
||||
|
||||
/// The contacts under one row and one column.
|
||||
public int Count(int row, int column) => counts[row, column];
|
||||
|
||||
public IReadOnlyList<int> RowTotals { get; }
|
||||
|
||||
/// The row total plus every row above it, which is N1MM's `Accum` column.
|
||||
public IReadOnlyList<int> Accumulated { get; }
|
||||
|
||||
public IReadOnlyList<int> ColumnTotals { get; }
|
||||
|
||||
public int Total { get; }
|
||||
|
||||
public bool IsEmpty => Rows.Count == 0;
|
||||
|
||||
/// N1MM counts a contact when it is in the claimed score and is not marked
|
||||
/// as a dupe. Nothing in this program writes the `D` contact type, but a log
|
||||
/// N1MM wrote can carry it.
|
||||
private static bool IsCounted(Qso qso) =>
|
||||
qso.IsClaimed && !qso.ContactType.Equals("D", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
Reference in New Issue
Block a user