Files
Nonemm/src/Nonemm.Session/StatisticsPivot.cs
T
erikbrockoandClaude Opus 5 a15bc8a6a2 Keep an excluded contact out of the score the way N1MM does
Three things were wrong with a contact the operator takes out of the claimed
score. The decompiled reference gives the rule in four places:

- `Contest.cs:437` returns zero points when `IsClaimedQso` is 0.
- `ContestInstance.cs:307` builds the where clause `CLAIMEDQSO = 1`, which the
  dupe search (`DBSupport.cs:1189`) and the multiplier search
  (`DBSupport.cs:1327`) both use, so an unclaimed contact takes nothing away
  from a later one.
- The score query (`DBSupport.cs:1807`) adds `ContactType <> 'D'`, which the
  other two do not. A contact marked as a dupe is therefore still searched for
  dupes and multipliers, and is left out of the score alone.
- `Contact.cs:1563`, `3307` and `3881` write `X-QSO:` rather than dropping the
  contact from the Cabrillo file.

`ContestLog` scored an unclaimed contact in full, and the Cabrillo writer left
it out of the file. `Qso.IsInScore` now holds the score rule and
`StatisticsPivot` uses it instead of its own copy.

The same reference also fixes the sent serial in three Cabrillo exporters.
ARRL 10M, Ukrainian DX and YO DX HF formatted the sent exchange from the
station rather than the contact, so a DX entrant exported `001` on every line.
N1MM reads the contact's own number: `CArrl10m.cs:122` writes it unpadded,
`CUkrainian.cs:232` and `CYOHFDX.cs:167` pad it to three digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
2026-09-09 20:12:51 +00:00

106 lines
3.3 KiB
C#

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 && !qso.IsInScore)
{
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;
}