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
+2 -1
View File
@@ -48,7 +48,8 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
| While transmitting | a digital message is held in the transmit pane and fed to the engine a character at a time, so what has not gone out can still be rewritten; what has gone out is red, and the engine idles when it catches up with the cursor | | While transmitting | a digital message is held in the transmit pane and fed to the engine a character at a time, so what has not gone out can still be rewritten; what has gone out is red, and the engine idles when it catches up with the cursor |
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, grey line, telnet, digital interface | | Windows | entry, log, check, bandmap, available mults and Qs, score summary, statistics, grey line, telnet, digital interface |
| Statistics | the log counted under any two of its fields, as N1MM's table with its `Tot` and `Accum` columns or as a stacked column graph |
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station | | Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
+22
View File
@@ -664,6 +664,28 @@ What still differs, and why:
nothing and counts no multiplier; the code is trimmed here and counts. Both nothing and counts no multiplier; the code is trimmed here and counts. Both
contacts are the same station in the 2026 log. contacts are the same station in the 2026 log.
**The statistics window: what N1MM has and this does not.** The window counts
the log under any two of its fields, in N1MM's own two lists — 29 fields down
the rows, 20 of them across the columns — with N1MM's `Tot` and `Accum` columns,
its `Total` line, its "Include Dupes & X-QSOs" box and its stacked column graph.
Left out: printing to a printer, which needs a print dialog this program has
nowhere else, and N1MM's font size spinner, because the theme dialog sets the
fonts here. Copy All copies the table as text on both tabs; N1MM copies the
graph as an image on the graph tab, which the clipboard on Linux would need a
per-toolkit path for, so the graph is written to a PNG through File ▸ Save Graph
instead.
The palette names are N1MM's twelve, which are the .NET chart control's. The
colours are not: they are chosen to match the name, because the control's own
values are not published anywhere this could read them from. Berry is purple
through magenta, Fire is red through yellow, and so on.
**The statistics graph is drawn here rather than by a chart library.**
`StatisticsChart` draws the axis, the stacked columns, the labels and the
legend. It has no tooltips, no zoom and no 3-D, all of which N1MM's chart
control gives it for free. A column label that will not fit under its column is
turned on its side, and one that will not fit even then is left out.
## Known rough edges ## Known rough edges
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no **The band plan covers 160M to 2M only.** 60M and everything above 2M get no
+16
View File
@@ -208,6 +208,22 @@ public sealed record Settings
public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding; public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding;
/// What the statistics window counts down the rows and across the columns.
/// N1MM opens on the contacts per hand per hour, so this program does too.
public string StatsRow { get; init; } = "Hour";
public string StatsColumn { get; init; } = "Band";
public string StatsPalette { get; init; } = "Berry";
/// N1MM's "Include Dupes &amp; X-QSOs".
public bool StatsIncludeDupes { get; init; }
public bool StatsShowTitle { get; init; } = true;
/// Which of the statistics window's two tabs was last open.
public bool StatsOnGraph { get; init; }
/// Sub-band boundaries the operator has changed. Empty means the defaults /// Sub-band boundaries the operator has changed. Empty means the defaults
/// in `BandPlan.Default`; an entry replaces one band's boundaries. /// in `BandPlan.Default`; an entry replaces one band's boundaries.
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = []; public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
+45
View File
@@ -0,0 +1,45 @@
using Avalonia.Media;
namespace Nonemm.App.Windows;
/// The twelve palettes N1MM's statistics graph offers. The names are the ones
/// in N1MM's box, which are the .NET chart control's palettes; the colours are
/// chosen to match the name, not copied out of that control.
public static class ChartPalettes
{
public static readonly IReadOnlyList<string> Names =
[
"Berry", "Bright", "Bright Pastel", "Chocolate", "Earth Tones", "Excel",
"Fire", "Gray Scale", "Light", "Pastel", "Sea Green", "Semi Transparent",
];
private static readonly Dictionary<string, uint[]> Colours = new()
{
["Berry"] = [0x8A2BE2, 0xBA55D3, 0xC71585, 0xDA70D6, 0x7B68EE, 0x9370DB, 0xFF69B4, 0x4B0082],
["Bright"] = [0x1F77B4, 0xFF7F0E, 0x2CA02C, 0xD62728, 0x9467BD, 0x8C564B, 0xE377C2, 0x17BECF],
["Bright Pastel"] = [0x418CF0, 0xFCB441, 0xE0400A, 0x056492, 0xBFBFBF, 0x1A3B69, 0xFFE382, 0x129CDD],
["Chocolate"] = [0x5B3A29, 0x8B4513, 0xA0522D, 0xCD853F, 0xD2B48C, 0x6B4226, 0xB87333, 0xE3C6A0],
["Earth Tones"] = [0x6B8E23, 0x808000, 0xBDB76B, 0x8B7355, 0x556B2F, 0xA0522D, 0xC0A16B, 0x4F5D2F],
["Excel"] = [0x4472C4, 0xED7D31, 0xA5A5A5, 0xFFC000, 0x5B9BD5, 0x70AD47, 0x264478, 0x9E480E],
["Fire"] = [0xB22222, 0xE34A00, 0xFF8C00, 0xFFC000, 0xFFE873, 0x8B0000, 0xFF4500, 0xFFA07A],
["Gray Scale"] = [0x2B2B2B, 0x4D4D4D, 0x6E6E6E, 0x8F8F8F, 0xB0B0B0, 0xC9C9C9, 0xE0E0E0, 0xF2F2F2],
["Light"] = [0xA6CEE3, 0xB2DF8A, 0xFDBF6F, 0xCAB2D6, 0xFB9A99, 0xFFFF99, 0xB3E2CD, 0xD9D9D9],
["Pastel"] = [0xAEC7E8, 0xFFBB78, 0x98DF8A, 0xFF9896, 0xC5B0D5, 0xC49C94, 0xF7B6D2, 0x9EDAE5],
["Sea Green"] = [0x2E8B57, 0x3CB371, 0x20B2AA, 0x66CDAA, 0x008B8B, 0x5F9EA0, 0x8FBC8F, 0x006400],
["Semi Transparent"] = [0x1F77B4, 0xFF7F0E, 0x2CA02C, 0xD62728, 0x9467BD, 0x8C564B, 0xE377C2, 0x17BECF],
};
/// The palette of that name, or the first one when there is no such name.
/// A series past the end of the list takes a colour again from the start,
/// as the chart control does.
public static IReadOnlyList<Color> Of(string name)
{
uint[] values = Colours.TryGetValue(name, out uint[]? found) ? found : Colours["Berry"];
byte alpha = name == "Semi Transparent" ? (byte)0xA0 : (byte)0xFF;
return [.. values.Select(v => Color.FromArgb(
alpha,
(byte)(v >> 16),
(byte)(v >> 8),
(byte)v))];
}
}
@@ -431,6 +431,10 @@ public sealed partial class EntryWindow
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session)); private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
/// N1MM's statistics window: the log counted under any two of its fields.
private void OnShowStatistics(object? sender, RoutedEventArgs e) =>
Show(() => new StatisticsWindow(session));
private void OnShowNetworkStatus(object? sender, RoutedEventArgs e) => private void OnShowNetworkStatus(object? sender, RoutedEventArgs e) =>
Show(() => new NetworkStatusWindow(session)); Show(() => new NetworkStatusWindow(session));
+1
View File
@@ -122,6 +122,7 @@
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" /> <MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
<MenuItem Header="Grey Line" Click="OnShowGrayline" /> <MenuItem Header="Grey Line" Click="OnShowGrayline" />
<MenuItem Header="Score Summary" Click="OnShowScore" /> <MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Statistics" Click="OnShowStatistics" />
<MenuItem Header="Telnet" Click="OnShowTelnet" /> <MenuItem Header="Telnet" Click="OnShowTelnet" />
<MenuItem Header="Digital Interface" Click="OnShowDigital" /> <MenuItem Header="Digital Interface" Click="OnShowDigital" />
</MenuItem> </MenuItem>
+229
View File
@@ -0,0 +1,229 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace Nonemm.App.Windows;
/// One column of the graph: the label under it and the count in each series.
public sealed record ChartColumn(string Label, IReadOnlyList<int> Values);
/// The statistics window's graph. N1MM draws a stacked column per row of the
/// table, one segment per column of the table, with the column values in a
/// legend along the bottom.
public sealed class StatisticsChart : Control
{
private const double Gutter = 12;
private const double AxisWidth = 52;
private const double LegendSwatch = 10;
private const double LegendRowHeight = 16;
private const double TitleHeight = 26;
/// Under this width a column is drawn without a gap either side, because
/// the gap would take more room than the column.
private const double NarrowColumn = 4;
private readonly Typeface typeface = new(new FontFamily("monospace"));
public IReadOnlyList<ChartColumn> Columns { get; set; } = [];
/// The name of each series, which is one value of the table's column field.
public IReadOnlyList<string> Series { get; set; } = [];
public IReadOnlyList<Color> Palette { get; set; } = ChartPalettes.Of("Berry");
public string Title { get; set; } = "";
public bool ShowTitle { get; set; } = true;
public override void Render(DrawingContext context)
{
IBrush ink = Foreground();
// the window's own colour, so the graph written out to a file has a
// background under it rather than a transparent one
if (this.FindResource("FormBackground") is IBrush sheet)
{
context.FillRectangle(sheet, new Rect(Bounds.Size));
}
if (Columns.Count == 0 || Series.Count == 0)
{
Write(context, "no contacts to graph", new Point(Gutter, Gutter), ink);
return;
}
double top = Gutter;
if (ShowTitle && Title.Length > 0)
{
FormattedText title = Text(Title, 14, ink, bold: true);
context.DrawText(title, new Point((Bounds.Width - title.Width) / 2, top));
top += TitleHeight;
}
double legend = LegendHeight();
double labels = LabelHeight();
Rect plot = new(
AxisWidth,
top,
Math.Max(1, Bounds.Width - AxisWidth - Gutter),
Math.Max(1, Bounds.Height - top - legend - labels - Gutter));
int highest = Highest();
DrawAxis(context, plot, highest, ink);
DrawColumns(context, plot, highest, ink);
DrawLegend(context, new Rect(Gutter, Bounds.Height - legend, Bounds.Width - (2 * Gutter), legend), ink);
}
/// The tallest stack, rounded up to a round number so the top gridline is
/// one a reader can divide by.
private int Highest()
{
int tallest = Columns.Max(c => c.Values.Sum());
if (tallest <= 0)
{
return 1;
}
int step = (int)Math.Pow(10, Math.Floor(Math.Log10(tallest)));
return (int)(Math.Ceiling((double)tallest / step) * step);
}
private void DrawAxis(DrawingContext context, Rect plot, int highest, IBrush ink)
{
Pen line = new(Opaque(ink, 0.35), 1);
Pen grid = new(Opaque(ink, 0.15), 1);
const int Lines = 4;
for (int at = 0; at <= Lines; at++)
{
double y = plot.Bottom - (plot.Height * at / Lines);
context.DrawLine(at == 0 ? line : grid, new Point(plot.Left, y), new Point(plot.Right, y));
FormattedText value = Text(
(highest * at / Lines).ToString(CultureInfo.InvariantCulture),
11,
Opaque(ink, 0.7));
context.DrawText(value, new Point(plot.Left - value.Width - 6, y - (value.Height / 2)));
}
context.DrawLine(line, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
}
private void DrawColumns(DrawingContext context, Rect plot, int highest, IBrush ink)
{
double slot = plot.Width / Columns.Count;
double gap = slot > NarrowColumn ? Math.Min(slot * 0.2, 6) : 0;
double width = Math.Max(1, slot - gap);
for (int at = 0; at < Columns.Count; at++)
{
double left = plot.Left + (at * slot) + (gap / 2);
double bottom = plot.Bottom;
for (int series = 0; series < Columns[at].Values.Count; series++)
{
int count = Columns[at].Values[series];
if (count <= 0)
{
continue;
}
double height = plot.Height * count / highest;
context.FillRectangle(
new SolidColorBrush(ColourOf(series)),
new Rect(left, bottom - height, width, height));
bottom -= height;
}
DrawColumnLabel(context, Columns[at].Label, left + (width / 2), plot.Bottom + 3, slot, ink);
}
}
/// A label wider than its column is turned on its side, which is what keeps
/// an hour-by-hour graph readable.
private void DrawColumnLabel(
DrawingContext context,
string label,
double centre,
double top,
double slot,
IBrush ink)
{
FormattedText text = Text(label, 11, Opaque(ink, 0.8));
if (text.Width <= slot - 2)
{
context.DrawText(text, new Point(centre - (text.Width / 2), top));
return;
}
if (slot < 7)
{
return;
}
using (context.PushTransform(
Matrix.CreateRotation(Math.PI / 2) * Matrix.CreateTranslation(centre + (text.Height / 2), top)))
{
context.DrawText(text, new Point(0, 0));
}
}
private void DrawLegend(DrawingContext context, Rect area, IBrush ink)
{
double x = area.Left;
double y = area.Top;
for (int at = 0; at < Series.Count; at++)
{
FormattedText name = Text(Series[at].Length > 0 ? Series[at] : "(none)", 11, ink);
double width = LegendSwatch + 4 + name.Width + 12;
if (x + width > area.Right && x > area.Left)
{
x = area.Left;
y += LegendRowHeight;
}
context.FillRectangle(
new SolidColorBrush(ColourOf(at)),
new Rect(x, y + 3, LegendSwatch, LegendSwatch));
context.DrawText(name, new Point(x + LegendSwatch + 4, y));
x += width;
}
}
private double LegendHeight()
{
double x = 0;
int rows = 1;
double width = Math.Max(1, Bounds.Width - (2 * Gutter));
foreach (string series in Series)
{
double item = LegendSwatch + 4
+ Text(series.Length > 0 ? series : "(none)", 11, Brushes.Gray).Width + 12;
if (x + item > width && x > 0)
{
x = 0;
rows++;
}
x += item;
}
return (rows * LegendRowHeight) + Gutter;
}
/// Room under the columns for the labels: one line when they fit side by
/// side, the longest label on its side when they do not.
private double LabelHeight()
{
double slot = Math.Max(1, Bounds.Width - AxisWidth - Gutter) / Math.Max(1, Columns.Count);
IEnumerable<FormattedText> labels = Columns.Select(c => Text(c.Label, 11, Brushes.Gray));
return labels.All(t => t.Width <= slot - 2)
? LegendRowHeight
: Columns.Max(c => Text(c.Label, 11, Brushes.Gray).Width) + 6;
}
private Color ColourOf(int series) => Palette[series % Palette.Count];
private FormattedText Text(string text, double size, IBrush ink, bool bold = false) =>
new(
text,
CultureInfo.InvariantCulture,
FlowDirection.LeftToRight,
bold ? new Typeface(typeface.FontFamily, weight: FontWeight.Bold) : typeface,
size,
ink);
private void Write(DrawingContext context, string text, Point at, IBrush ink) =>
context.DrawText(Text(text, 12, ink), at);
private IBrush Foreground() =>
this.FindResource("SystemControlForegroundBaseHighBrush") as IBrush ?? Brushes.Gray;
private static IBrush Opaque(IBrush ink, double opacity) =>
ink is ISolidColorBrush solid ? new SolidColorBrush(solid.Color, opacity) : ink;
}
+27
View File
@@ -0,0 +1,27 @@
namespace Nonemm.App.Windows;
/// One line of the statistics table. The grid reaches the cells through the
/// indexer, so the columns can follow the two fields chosen instead of being
/// fixed in the XAML.
public sealed class StatisticsRow
{
private readonly string[] cells;
public StatisticsRow(IReadOnlyList<string> cells, bool isTotal = false)
{
this.cells = [.. cells];
IsTotal = isTotal;
}
/// The last line of the table, which holds the column totals.
public bool IsTotal { get; }
public IReadOnlyList<string> Cells => cells;
/// `at` is the column number as text: the grid passes what stands in the
/// binding path, and a column is bound to `[3]`.
public string this[string at] =>
int.TryParse(at, out int column) && column >= 0 && column < cells.Length
? cells[column]
: "";
}
@@ -0,0 +1,48 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.StatisticsWindow"
Title="Statistics" Width="940" Height="560">
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="Copy All" Click="OnCopyAll" InputGesture="Ctrl+C" />
<MenuItem Name="PrintToFileItem" Header="Print to File…" Click="OnPrintToFile" />
<MenuItem Name="SaveGraphItem" Header="Save Graph…" Click="OnSaveGraph" />
<MenuItem Name="ShowTitleItem" Header="Show Title" ToggleType="CheckBox"
Click="OnToggleTitle" />
</MenuItem>
</Menu>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="6,4" Spacing="6">
<Button Name="RefreshButton" Content="Refresh" Click="OnRefreshClicked" />
<TextBlock Text="Row" VerticalAlignment="Center" />
<ComboBox Name="RowBox" Width="130" SelectionChanged="OnFieldChanged" />
<TextBlock Text="Column" VerticalAlignment="Center" />
<ComboBox Name="ColumnBox" Width="130" SelectionChanged="OnFieldChanged" />
<TextBlock Name="PaletteLabel" Text="Palette" VerticalAlignment="Center" />
<ComboBox Name="PaletteBox" Width="150" SelectionChanged="OnPaletteChanged" />
<CheckBox Name="DupesBox" Content="Include Dupes &amp; X-QSOs" IsCheckedChanged="OnDupesChanged" />
</StackPanel>
<TabControl Name="Tabs" SelectionChanged="OnTabChanged">
<TabItem Header="Statistics">
<Grid>
<Grid.Styles>
<!-- the DataGrid's own font settings do not reach the cells -->
<Style Selector="DataGridCell TextBlock">
<Setter Property="FontFamily" Value="monospace" />
<Setter Property="FontSize" Value="12" />
</Style>
</Grid.Styles>
<DataGrid Name="Table" IsReadOnly="True" CanUserSortColumns="False"
GridLinesVisibility="All" FontFamily="monospace" FontSize="12" />
<TextBlock Name="EmptyText" Margin="12" IsVisible="False" />
</Grid>
</TabItem>
<TabItem Header="Graph">
<local:StatisticsChart Name="Chart" />
</TabItem>
</TabControl>
</DockPanel>
</local:RefreshableWindow>
@@ -0,0 +1,312 @@
using System.Globalization;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// N1MM's statistics window: the contacts counted under any two of the log's
/// fields, as a table or as a stacked column graph.
public sealed partial class StatisticsWindow : RefreshableWindow
{
/// What the graph is written out at when the window is smaller than this.
private static readonly PixelSize GraphSize = new(1200, 800);
private readonly AppSession session;
/// Set while the boxes are being filled from the settings, so filling one
/// does not count as the operator choosing it.
private bool loading = true;
public StatisticsWindow(AppSession session)
{
this.session = session;
InitializeComponent();
Table.LoadingRow += (_, e) => e.Row.FontWeight =
e.Row.DataContext is StatisticsRow { IsTotal: true } ? FontWeight.Bold : FontWeight.Normal;
RowBox.ItemsSource = StatisticsFields.Rows.Select(f => f.Name).ToList();
ColumnBox.ItemsSource = StatisticsFields.Columns.Select(f => f.Name).ToList();
PaletteBox.ItemsSource = ChartPalettes.Names;
RowBox.SelectedItem = Row.Name;
ColumnBox.SelectedItem = Column.Name;
PaletteBox.SelectedItem = ChartPalettes.Names.Contains(session.Settings.StatsPalette)
? session.Settings.StatsPalette
: ChartPalettes.Names[0];
DupesBox.IsChecked = session.Settings.StatsIncludeDupes;
ShowTitleItem.IsChecked = session.Settings.StatsShowTitle;
Tabs.SelectedIndex = session.Settings.StatsOnGraph ? 1 : 0;
loading = false;
ShowPaletteBox();
Refresh();
}
private StatisticsField Row =>
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
private StatisticsField Column =>
StatisticsFields.Find(StatisticsFields.Columns, session.Settings.StatsColumn);
public override void Refresh()
{
if (session.Logging is null)
{
Table.Columns.Clear();
Table.ItemsSource = Array.Empty<StatisticsRow>();
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
Empty("no contest is open");
return;
}
StatisticsPivot pivot = StatisticsPivot.Of(
session.Logging.Log.Qsos,
Row,
Column,
session.Logging.Instance.StartDate,
session.Settings.StatsIncludeDupes);
Title = $"Statistics for {session.Logging.Contest.Name}"
+ $" - {session.Logging.Instance.StartDate:yyyy-MM-dd}"
+ $" - {Column.Name} by {Row.Name}";
if (pivot.IsEmpty)
{
Table.Columns.Clear();
Table.ItemsSource = Array.Empty<StatisticsRow>();
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
Empty("no contacts are counted");
return;
}
EmptyText.IsVisible = false;
Table.IsVisible = true;
FillTable(pivot);
FillChart(pivot);
}
private void Empty(string why)
{
EmptyText.Text = why;
EmptyText.IsVisible = true;
Table.IsVisible = false;
}
private void FillTable(StatisticsPivot pivot)
{
List<string> headers =
[
.. pivot.Row.Headings,
.. pivot.Columns.Select(c => c.Label.Length > 0 ? c.Label : "(none)"),
"Tot",
"Accum",
];
Table.Columns.Clear();
for (int at = 0; at < headers.Count; at++)
{
Table.Columns.Add(new DataGridTextColumn
{
Header = headers[at],
Width = DataGridLength.Auto,
Binding = new Binding($"[{at}]"),
});
}
Table.ItemsSource = Lines(pivot);
}
private static List<StatisticsRow> Lines(StatisticsPivot pivot)
{
List<StatisticsRow> lines = [];
int labels = pivot.Row.Headings.Count;
for (int down = 0; down < pivot.Rows.Count; down++)
{
List<string> cells = [pivot.Rows[down].First];
if (labels == 2)
{
cells.Add(pivot.Rows[down].Second);
}
for (int across = 0; across < pivot.Columns.Count; across++)
{
cells.Add(Number(pivot.Count(down, across)));
}
cells.Add(Number(pivot.RowTotals[down]));
cells.Add(Number(pivot.Accumulated[down]));
lines.Add(new StatisticsRow(cells));
}
List<string> total = ["Total"];
if (labels == 2)
{
total.Add("");
}
total.AddRange(pivot.ColumnTotals.Select(Number));
total.Add(Number(pivot.Total));
total.Add(Number(pivot.Total));
lines.Add(new StatisticsRow(total, isTotal: true));
return lines;
}
/// N1MM leaves a cell with nothing in it blank rather than printing a zero,
/// so the shape of the operating shows through the table.
private static string Number(int count) =>
count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture);
private void FillChart(StatisticsPivot pivot)
{
Chart.Series = [.. pivot.Columns.Select(c => c.Label)];
Chart.Columns =
[
.. Enumerable.Range(0, pivot.Rows.Count).Select(down => new ChartColumn(
Under(pivot.Rows[down]),
[.. Enumerable.Range(0, pivot.Columns.Count).Select(across => pivot.Count(down, across))])),
];
Chart.Palette = ChartPalettes.Of(session.Settings.StatsPalette);
Chart.Title = session.Logging is null
? ""
: $"{session.Logging.Me.Callsign} - {session.Logging.Contest.DisplayName}"
+ $" {session.Logging.Instance.StartDate:yyyy-MM-dd}";
Chart.ShowTitle = session.Settings.StatsShowTitle;
Chart.InvalidateVisual();
}
/// N1MM labels the graph with the last of the row's label columns, so an
/// hour-by-hour graph reads 07, 08, 09 and not the date over and over.
private static string Under(PivotKey key) => key.Second.Length > 0 ? key.Second : key.First;
private void OnRefreshClicked(object? sender, RoutedEventArgs e) => Refresh();
private void OnFieldChanged(object? sender, SelectionChangedEventArgs e)
{
if (loading)
{
return;
}
session.Save(session.Settings with
{
StatsRow = RowBox.SelectedItem as string ?? session.Settings.StatsRow,
StatsColumn = ColumnBox.SelectedItem as string ?? session.Settings.StatsColumn,
});
Refresh();
}
private void OnPaletteChanged(object? sender, SelectionChangedEventArgs e)
{
if (loading)
{
return;
}
session.Save(session.Settings with
{
StatsPalette = PaletteBox.SelectedItem as string ?? session.Settings.StatsPalette,
});
Refresh();
}
private void OnDupesChanged(object? sender, RoutedEventArgs e)
{
if (loading)
{
return;
}
session.Save(session.Settings with { StatsIncludeDupes = DupesBox.IsChecked == true });
Refresh();
}
private void OnTabChanged(object? sender, SelectionChangedEventArgs e)
{
if (loading)
{
return;
}
session.Save(session.Settings with { StatsOnGraph = Tabs.SelectedIndex == 1 });
ShowPaletteBox();
}
/// The palette box belongs to the graph, and printing the table to a file
/// belongs to the table, so each is offered only on its own tab.
private void ShowPaletteBox()
{
bool onGraph = Tabs.SelectedIndex == 1;
PaletteBox.IsVisible = onGraph;
PaletteLabel.IsVisible = onGraph;
PrintToFileItem.IsEnabled = !onGraph;
SaveGraphItem.IsEnabled = onGraph;
}
private void OnToggleTitle(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with { StatsShowTitle = ShowTitleItem.IsChecked });
Refresh();
}
private async void OnCopyAll(object? sender, RoutedEventArgs e)
{
if (Clipboard is { } clipboard)
{
await clipboard.SetTextAsync(AsText(", "));
}
}
private async void OnPrintToFile(object? sender, RoutedEventArgs e)
{
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Print statistics to file",
SuggestedFileName = "statistics.txt",
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
});
if (file?.TryGetLocalPath() is { } path)
{
await File.WriteAllTextAsync(path, AsText("\t"));
}
}
private async void OnSaveGraph(object? sender, RoutedEventArgs e)
{
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save the graph",
SuggestedFileName = "statistics.png",
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
PixelSize size = Chart.Bounds.Width >= 1 && Chart.Bounds.Height >= 1
? new PixelSize((int)Chart.Bounds.Width, (int)Chart.Bounds.Height)
: GraphSize;
using RenderTargetBitmap bitmap = new(size);
bitmap.Render(Chart);
bitmap.Save(path, new PngBitmapEncoderOptions());
}
private async Task<IStorageFolder?> Folder(string path) =>
Directory.Exists(path) ? await StorageProvider.TryGetFolderFromPathAsync(path) : null;
/// The table as text, one line per row. N1MM's Copy All separates the
/// values with commas and its Print to File lines the columns up.
private string AsText(string between)
{
StringBuilder text = new();
text.AppendLine(string.Join(between, Table.Columns.Select(c => c.Header)));
if (Table.ItemsSource is IEnumerable<StatisticsRow> lines)
{
foreach (StatisticsRow line in lines)
{
text.AppendLine(string.Join(between, line.Cells));
}
}
return text.ToString();
}
}
+33
View File
@@ -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 &amp; 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);
}
}
+27
View File
@@ -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;
}
+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);
});
}
+111
View File
@@ -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);
}
@@ -0,0 +1,166 @@
using Nonemm.Core;
namespace Nonemm.Session.Tests;
public class StatisticsPivotTests
{
private static readonly DateTime Start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static Qso Contact(
string call = "DL1ABC",
double kilohertz = 14_030,
Mode? mode = null,
int minute = 0,
int points = 1,
string operatorCall = "OM3ABC")
{
return new Qso
{
Id = Qso.NewId(),
TimestampUtc = Start.AddMinutes(minute),
Call = Callsign.Parse(call),
Frequency = Frequency.FromKilohertz(kilohertz),
Mode = mode ?? Modes.Cw,
ContestName = "CQWW",
Points = points,
Operator = operatorCall,
};
}
private static StatisticsPivot Of(
IReadOnlyList<Qso> qsos,
string row,
string column,
bool includeDupes = false)
{
return StatisticsPivot.Of(
qsos,
StatisticsFields.Find(StatisticsFields.Rows, row),
StatisticsFields.Find(StatisticsFields.Columns, column),
Start,
includeDupes);
}
[Fact]
public void CountsTheContactsUnderEachPairOfValues()
{
List<Qso> log =
[
Contact(kilohertz: 14_030),
Contact(kilohertz: 14_040),
Contact(kilohertz: 7_030),
Contact(kilohertz: 7_040, mode: Modes.Usb),
];
StatisticsPivot pivot = Of(log, "Band", "Mode");
Assert.Equal(["40M", "20M"], pivot.Rows.Select(r => r.Label));
Assert.Equal(["CW", "USB"], pivot.Columns.Select(c => c.Label));
Assert.Equal(1, pivot.Count(0, 0));
Assert.Equal(1, pivot.Count(0, 1));
Assert.Equal(2, pivot.Count(1, 0));
Assert.Equal(0, pivot.Count(1, 1));
}
[Fact]
public void TotalsTheRowsTheColumnsAndTheWholeTable()
{
List<Qso> log =
[
Contact(kilohertz: 7_030),
Contact(kilohertz: 14_030),
Contact(kilohertz: 14_040),
Contact(kilohertz: 14_050, mode: Modes.Usb),
];
StatisticsPivot pivot = Of(log, "Band", "Mode");
Assert.Equal([1, 3], pivot.RowTotals);
Assert.Equal([1, 4], pivot.Accumulated);
Assert.Equal([3, 1], pivot.ColumnTotals);
Assert.Equal(4, pivot.Total);
}
[Fact]
public void SortsANumericFieldByItsNumberAndNotByItsText()
{
List<Qso> log = [Contact(points: 10), Contact(points: 2), Contact(points: 1)];
StatisticsPivot pivot = Of(log, "Points", "Mode");
Assert.Equal(["1", "2", "10"], pivot.Rows.Select(r => r.Label));
}
[Fact]
public void SortsABandByItsFrequencyAndNotByItsName()
{
List<Qso> log = [Contact(kilohertz: 14_030), Contact(kilohertz: 3_530), Contact(kilohertz: 7_030)];
StatisticsPivot pivot = Of(log, "Band", "Mode");
Assert.Equal(["80M", "40M", "20M"], pivot.Rows.Select(r => r.Label));
}
[Fact]
public void PutsTheDayAndTheHourInTwoLabelColumns()
{
List<Qso> log = [Contact(minute: 5), Contact(minute: 70), Contact(minute: 75)];
StatisticsPivot pivot = Of(log, "Hour", "Mode");
Assert.Equal(["Day", "Hr"], pivot.Row.Headings);
Assert.Equal([("2026-01-01", "00"), ("2026-01-01", "01")], pivot.Rows.Select(r => (r.First, r.Second)));
Assert.Equal([1, 2], pivot.RowTotals);
}
[Fact]
public void CountsTheMinuteFieldsFromTheStartOfTheContest()
{
List<Qso> log = [Contact(minute: 0), Contact(minute: 4), Contact(minute: 6), Contact(minute: 12)];
StatisticsPivot pivot = Of(log, "5 Minute", "Mode");
Assert.Equal(["0", "5", "10"], pivot.Rows.Select(r => r.Label));
Assert.Equal([2, 1, 1], pivot.RowTotals);
}
[Fact]
public void LeavesOutTheContactsNotInTheClaimedScoreUnlessTheyAreAskedFor()
{
List<Qso> log =
[
Contact(),
Contact() with { IsClaimed = false },
Contact() with { ContactType = "D" },
];
Assert.Equal(1, Of(log, "Band", "Mode").Total);
Assert.Equal(3, Of(log, "Band", "Mode", includeDupes: true).Total);
}
[Fact]
public void KeepsAFieldWithNoValueAsARowOfItsOwn()
{
List<Qso> log = [Contact(operatorCall: "OM3ABC"), Contact(operatorCall: "")];
StatisticsPivot pivot = Of(log, "Operator", "Mode");
Assert.Equal(["", "OM3ABC"], pivot.Rows.Select(r => r.Label));
}
[Fact]
public void SaysTheTableIsEmptyWhenNothingIsCounted()
{
Assert.True(Of([], "Band", "Mode").IsEmpty);
Assert.Equal(0, Of([], "Band", "Mode").Total);
}
[Fact]
public void OffersEveryRowFieldAsAColumnExceptTheOnesThatWouldNotFit()
{
Assert.Equal(29, StatisticsFields.Rows.Count);
Assert.Equal(20, StatisticsFields.Columns.Count);
Assert.DoesNotContain(StatisticsFields.Columns, f => f.Name == "Call");
Assert.Contains(StatisticsFields.Rows, f => f.Name == "Call");
}
}