Add the Broadcast Data dialog and send N1MM's score breakdown

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 03:42:19 +00:00
co-authored by Claude Sonnet 5.5
parent ec12fb1d0f
commit 65178b2d89
24 changed files with 1325 additions and 21 deletions
@@ -0,0 +1,62 @@
using Nonemm.Core;
namespace Nonemm.Contests;
/// The multiplier names N1MM keeps in the `Contest` table, such as `ZN`,
/// `CountryPrefix` and `Sect`. The score broadcast names its multiplier types
/// from them, and a contest's own `MultiplierNamesFor` holds the names the
/// score window shows instead (`Zones`, `Countries`). Taken from a log N1MM
/// wrote, without the `N/A` entries.
public static class N1mmMultiplierNames
{
private static readonly Dictionary<string, IReadOnlyList<string>> Known =
new(StringComparer.OrdinalIgnoreCase)
{
["CQWWCW"] = ["ZN", "CountryPrefix"],
["CQWWSSB"] = ["ZN", "CountryPrefix"],
["CQWWRTTY"] = ["ZN", "CountryPrefix", "Sect"],
["CQWPXCW"] = ["WPXPrefix"],
["CQWPXSSB"] = ["WPXPrefix"],
["CQWPXRTTY"] = ["WPXPrefix"],
["ARRLDXCW"] = ["Sect"],
["ARRLDXSSB"] = ["Sect"],
["IARU"] = ["ZN", "HQ"],
["SSCW"] = ["Sect"],
["SSSSB"] = ["Sect"],
["WAECW"] = ["CountryPrefix"],
["WAESSB"] = ["CountryPrefix"],
["WAERTTY"] = ["CountryPrefix"],
["ARRLRTTY"] = ["Sect", "CountryPrefix"],
["NAQPCW"] = ["Sect", "NA"],
["NAQPSSB"] = ["Sect", "NA"],
["NAQPRTTY"] = ["Sect", "NA"],
["OKOMDX"] = ["Sect"],
["YOTA"] = ["Exchange"],
["IOTA"] = ["Sect"],
["EUDXC"] = ["Sect", "CountryPrefix"],
["CQ160CW"] = ["StateProvince", "CountryPrefix"],
["CQ160SSB"] = ["StateProvince", "CountryPrefix"],
["XERTTY"] = ["Countryprefix", "Sect"],
["REFCW"] = ["Sect", "CountryPrefix"],
["REFSSB"] = ["Sect", "CountryPrefix"],
["UKRAINDX"] = ["CountryPrefix", "Sect"],
["BARTGRTTYS"] = ["CountryPrefix", "Sect", "Continent"],
["ARRL10M"] = ["StateProvince", "CountryPrefix"],
["RUSDXRTTY"] = ["CountryPrefix", "Sect"],
["RDAC"] = ["CountryPrefix", "Sect"],
["YOHFDX"] = ["CountryPrefix", "Sect"],
["OCEANIACW"] = ["WPXPrefix"],
["OCEANIASSB"] = ["WPXPrefix"],
["FDREG1"] = ["CountryPrefix"],
["SARTGRTTY"] = ["CountryPrefix", "Sect"],
["ALLASIACW"] = ["WPXPrefix"],
["ALLASIASSB"] = ["WPXPrefix"],
["DX"] = ["CountryPrefix"],
};
/// A contest not in the table gets the names its rules give.
public static IReadOnlyList<string> For(Contest contest, StationInfo me) =>
Known.TryGetValue(contest.Name, out IReadOnlyList<string>? names)
? names
: contest.MultiplierNamesFor(me);
}
+72
View File
@@ -0,0 +1,72 @@
using System.Globalization;
using Nonemm.Core;
namespace Nonemm.Contests;
/// One row of N1MM's score query: the contacts of one band and mode, or of all
/// of them when `Band` is `ScoreBreakdown.TotalBand`. `Kind` is empty except in
/// WAE, which gives contacts and traffic a row each.
public sealed record ScoreBreakdownRow(
double Band,
string Mode,
string Kind,
int Qsos,
int Points,
IReadOnlyList<int> Multipliers);
/// The score the way N1MM's database query splits it: a row per band and mode
/// of the contact, with the multiplier flags added up, and a total row last
/// when there is any contact.
/// `ContestLog.Rows` splits by band alone, which is what the score window shows.
public static class ScoreBreakdown
{
/// The band N1MM gives the total row.
public const double TotalBand = 900_000_000;
public const string TotalMode = "Both";
/// Ordered the way the query orders them: by the band as text, so `10.0`
/// comes before `3.5`, then by mode. Only contacts in the claimed score
/// are counted.
public static IReadOnlyList<ScoreBreakdownRow> For(IEnumerable<Qso> qsos, Contest contest)
{
List<Qso> counted = qsos.Where(q => q.IsInScore).ToList();
List<ScoreBreakdownRow> rows = counted
.Where(q => q.Mode.Name.Length > 0)
.GroupBy(q => (Band: Rounded(q), Mode: q.Mode.Name, Kind: contest.RowKindFor(q)))
.OrderBy(g => BandText(g.Key.Band), StringComparer.Ordinal)
.ThenBy(g => g.Key.Mode, StringComparer.Ordinal)
.ThenBy(g => g.Key.Kind, StringComparer.Ordinal)
.Select(g => Row(g.Key.Band, g.Key.Mode, g.Key.Kind, g))
.ToList();
// the query's total is a group of the same rows, so an empty log has none
if (counted.Count > 0)
{
rows.Add(Row(TotalBand, TotalMode, contest.HasQtcTraffic ? "All" : "", counted));
}
return rows;
}
/// The band as the database stores it: the megahertz label to one decimal.
private static double Rounded(Qso qso) =>
Math.Round(qso.Band?.MegahertzLabel ?? 0, 1, MidpointRounding.AwayFromZero);
/// What SQLite writes for a real number cast to text.
private static string BandText(double band) => band.ToString("0.0", CultureInfo.InvariantCulture);
private static ScoreBreakdownRow Row(double band, string mode, string kind, IEnumerable<Qso> qsos)
{
List<Qso> rows = [.. qsos];
return new ScoreBreakdownRow(
band,
mode,
kind,
rows.Count,
rows.Sum(q => q.Points),
[
rows.Count(q => q.IsMultiplier1),
rows.Count(q => q.IsMultiplier2),
rows.Count(q => q.IsMultiplier3),
]);
}
}