Add the core, contest, storage and format layers

Frequencies, bands, modes, callsigns, grid squares and the country file live in
Nonemm.Core. Nonemm.Contests holds the scoring engine and CQ WW and CQ WPX.
Nonemm.Storage writes N1MM's DXLOG schema, and Nonemm.Formats writes Cabrillo
3.0 and reads and writes ADIF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 10:27:51 +00:00
commit 0f84bc8972
68 changed files with 3823 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
namespace Nonemm.Contests;
/// One column of a Cabrillo QSO line. Sponsors read these by column, so each
/// carries the width it is padded to.
public sealed record CabrilloField(string Value, int Width);
/// The exchange columns of one Cabrillo QSO line, in the order the sponsor's
/// template lists them.
public sealed record CabrilloExchange(
IReadOnlyList<CabrilloField> Sent,
IReadOnlyList<CabrilloField> Received);

View File

@@ -0,0 +1,41 @@
using Nonemm.Core;
namespace Nonemm.Contests;
/// One contest's rules: what is exchanged, when a station may be worked again,
/// what a contact scores and which multipliers it brings in.
public interface Contest
{
/// The short name the log stores, e.g. `CQWW`.
string Name { get; }
string DisplayName { get; }
/// The name the sponsor's Cabrillo header asks for.
string CabrilloName { get; }
IReadOnlyList<ExchangeField> ExchangeFields { get; }
/// Up to three names, in the order the score summary shows them.
IReadOnlyList<string> MultiplierNames { get; }
DupeScope DupeScope { get; }
/// True when serial numbers count up across the whole contest rather than
/// per band.
bool HasSerialNumbers { get; }
/// The modes the contest runs; empty means any.
IReadOnlyList<ModeCategory> Modes { get; }
string SentExchangeFor(StationInfo me);
int PointsFor(QsoContext qso);
IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso);
int TotalScore(ScoreTally tally);
/// The exchange columns of this contact's Cabrillo line.
CabrilloExchange CabrilloExchange(Qso qso, StationInfo me);
}

View File

@@ -0,0 +1,150 @@
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Contests;
/// The contacts of one contest, with the indexes that answer "worked before?"
/// and "new multiplier?" while the operator types.
public sealed class ContestLog
{
private readonly Contest contest;
private readonly StationInfo me;
private readonly CountryFile? countries;
private readonly List<Qso> qsos = [];
private readonly HashSet<string> workedKeys = new(StringComparer.Ordinal);
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
private ScoreTally tally = new();
public ContestLog(Contest contest, StationInfo me, CountryFile? countries)
{
this.contest = contest;
this.me = me;
this.countries = countries;
}
public Contest Contest => contest;
public IReadOnlyList<Qso> Qsos => qsos;
public ScoreTally Tally => tally;
public int TotalScore => contest.TotalScore(tally);
/// What logging this contact right now would do, without logging it.
public Verdict Judge(Qso candidate)
{
QsoContext context = ContextFor(candidate);
if (workedKeys.Contains(DupeKey(candidate)))
{
return Verdict.Dupe;
}
List<Multiplier> newOnes = [];
foreach (Multiplier multiplier in contest.MultipliersFor(context))
{
if (!claimedMultipliers.Contains(MultiplierKey(multiplier)))
{
newOnes.Add(multiplier);
}
}
return new Verdict(false, contest.PointsFor(context), newOnes);
}
/// Adds the contact and returns it with points and multiplier flags filled in.
public Qso Add(Qso qso)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
return scored;
}
/// Loads contacts read back from storage. Points and multiplier flags in
/// the stored rows are ignored and worked out again from the rules.
public void Restore(IEnumerable<Qso> stored)
{
qsos.AddRange(stored);
Rebuild();
}
public void Remove(string id)
{
qsos.RemoveAll(q => q.Id == id);
Rebuild();
}
public void Replace(Qso qso)
{
int at = qsos.FindIndex(q => q.Id == qso.Id);
if (at < 0)
{
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
}
qsos[at] = qso;
Rebuild();
}
public bool IsWorked(Qso candidate) => workedKeys.Contains(DupeKey(candidate));
/// Every contact with this call, newest first.
public IReadOnlyList<Qso> WorkedBefore(string call) =>
qsos.Where(q => string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(q => q.TimestampUtc)
.ToList();
private QsoContext ContextFor(Qso qso) =>
new(qso, countries?.Find(qso.Call), me);
private Qso ApplyVerdict(Qso qso, Verdict verdict) => qso with
{
Points = verdict.Points,
IsMultiplier1 = verdict.NewMultipliers.Any(m => m.Index == 1),
IsMultiplier2 = verdict.NewMultipliers.Any(m => m.Index == 2),
IsMultiplier3 = verdict.NewMultipliers.Any(m => m.Index == 3),
};
private void Index(Qso qso, Verdict verdict)
{
workedKeys.Add(DupeKey(qso));
tally.AddQso(verdict.Points);
foreach (Multiplier multiplier in verdict.NewMultipliers)
{
claimedMultipliers.Add(MultiplierKey(multiplier));
tally.AddMultiplier(multiplier.Index);
}
}
/// Rescores the whole log. A removed or edited contact can hand its
/// multiplier to a later one, which only a full pass gets right.
private void Rebuild()
{
workedKeys.Clear();
claimedMultipliers.Clear();
tally = new ScoreTally();
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
qsos.Clear();
foreach (Qso qso in ordered)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
}
}
private string DupeKey(Qso qso)
{
string call = qso.Call.Text.ToUpperInvariant();
return contest.DupeScope switch
{
DupeScope.Once => call,
DupeScope.PerBand => $"{call}|{qso.Band?.Name}",
DupeScope.PerMode => $"{call}|{qso.Mode.Category}",
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
_ => call,
};
}
private static string MultiplierKey(Multiplier multiplier) =>
$"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
}

View File

@@ -0,0 +1,17 @@
namespace Nonemm.Contests;
/// When a station may be worked again.
public enum DupeScope
{
/// Once in the whole contest.
Once,
/// Once per band, whatever the mode.
PerBand,
/// Once per band and mode.
PerBandAndMode,
/// Once per mode, whatever the band.
PerMode,
}

View File

@@ -0,0 +1,27 @@
namespace Nonemm.Contests;
/// One box in the entry window's exchange.
public sealed record ExchangeField(
string Label,
ExchangeSlot Slot,
ExchangeFieldKind Kind,
bool IsRequired = true)
{
/// How wide the box needs to be, in characters.
public int Width { get; init; } = DefaultWidth(Kind);
private static int DefaultWidth(ExchangeFieldKind kind) => kind switch
{
ExchangeFieldKind.Report => 3,
ExchangeFieldKind.Number => 5,
ExchangeFieldKind.CqZone => 2,
ExchangeFieldKind.ItuZone => 2,
ExchangeFieldKind.UsStateOrCanadianProvince => 3,
ExchangeFieldKind.ArrlSection => 3,
ExchangeFieldKind.Grid => 6,
ExchangeFieldKind.Precedence => 1,
ExchangeFieldKind.Check => 2,
ExchangeFieldKind.Power => 4,
_ => 8,
};
}

View File

@@ -0,0 +1,18 @@
namespace Nonemm.Contests;
/// What an exchange box accepts, which decides how it is checked and what the
/// entry window offers while it is being typed.
public enum ExchangeFieldKind
{
Report,
Number,
Text,
UsStateOrCanadianProvince,
ArrlSection,
CqZone,
ItuZone,
Grid,
Power,
Precedence,
Check,
}

View File

@@ -0,0 +1,20 @@
namespace Nonemm.Contests;
/// Which field of a QSO an exchange box fills in. The names match the log
/// columns so an exchange lands in the same place N1MM would put it.
public enum ExchangeSlot
{
ReceivedReport,
SerialNumber,
Zone,
Section,
Check,
Precedence,
Exchange1,
MiscText,
Name,
Qth,
GridSquare,
Power,
Comment,
}

View File

@@ -0,0 +1,8 @@
namespace Nonemm.Contests;
/// One multiplier a QSO claims. `Scope` is what makes it distinct: a country
/// counted once per band carries the band, one counted once carries nothing.
public sealed record Multiplier(int Index, string Value, string Scope)
{
public override string ToString() => Scope.Length == 0 ? Value : $"{Value}/{Scope}";
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,25 @@
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Contests;
/// A QSO together with what the country file says about it, so a contest can
/// score by country, zone or continent without looking anything up itself.
public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me)
{
public Band? Band => Qso.Band;
public ModeCategory ModeCategory => Qso.Mode.Category;
public string Continent => Country?.Continent ?? Qso.Continent;
public string CountryPrefix => Country?.Entity.PrimaryPrefix ?? Qso.CountryPrefix;
public int CqZone => Country?.CqZone ?? Qso.Zone;
public int ItuZone => Country?.ItuZone ?? 0;
public bool IsSameCountry => CountryPrefix.Length > 0 && CountryPrefix == Me.CountryPrefix;
public bool IsSameContinent => Continent.Length > 0 && Continent == Me.Continent;
}

View File

@@ -0,0 +1,79 @@
using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// CQ World Wide DX, CW and SSB. Points by continent, multipliers are zones and
/// countries counted once per band. The country list is DXCC plus WAE, which is
/// what `wl_cty.dat` holds.
public sealed class CqWorldWide : Contest
{
private readonly ModeCategory mode;
public CqWorldWide(ModeCategory mode) => this.mode = mode;
public string Name => "CQWW";
public string DisplayName => $"CQ World Wide DX {ModeLabel()}";
public string CabrilloName => mode == ModeCategory.Cw ? "CQ-WW-CW" : "CQ-WW-SSB";
public IReadOnlyList<ExchangeField> ExchangeFields =>
[
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone),
];
public IReadOnlyList<string> MultiplierNames => ["Zones", "Countries"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => false;
public IReadOnlyList<ModeCategory> Modes => [mode];
public string SentExchangeFor(StationInfo me) =>
$"{DefaultReport()} {me.CqZone}";
public int PointsFor(QsoContext qso)
{
if (!qso.Qso.Call.CountsForEntity)
{
return 0;
}
if (qso.IsSameCountry)
{
return 0;
}
if (!qso.IsSameContinent)
{
return 3;
}
return qso.Continent == "NA" ? 2 : 1;
}
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
string band = qso.Band?.Name ?? "";
List<Multiplier> found = [];
if (qso.Qso.Zone > 0)
{
found.Add(new Multiplier(1, qso.Qso.Zone.ToString(), band));
}
if (qso.CountryPrefix.Length > 0 && qso.Qso.Call.CountsForEntity)
{
found.Add(new Multiplier(2, qso.CountryPrefix, band));
}
return found;
}
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
new(
[new CabrilloField(qso.SentReport, 3), new CabrilloField(me.CqZone.ToString(), 6)],
[new CabrilloField(qso.ReceivedReport, 3), new CabrilloField(qso.Zone.ToString(), 6)]);
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
}

View File

@@ -0,0 +1,85 @@
using Nonemm.Core;
namespace Nonemm.Contests.Rules;
/// CQ WPX, CW, SSB and RTTY. Serial numbers are exchanged and the multiplier is
/// the prefix, counted once for the whole contest whatever the band.
public sealed class CqWpx : Contest
{
/// The rules split the bands at 14 MHz rather than by band name, so 30, 17
/// and 12 metres fall on the high side even though the contest is not run
/// on them.
private static readonly Frequency LowBandLimit = Frequency.FromKilohertz(14_000);
private readonly ModeCategory mode;
public CqWpx(ModeCategory mode) => this.mode = mode;
public string Name => "CQWPX";
public string DisplayName => $"CQ WPX {ModeLabel()}";
public string CabrilloName => mode switch
{
ModeCategory.Cw => "CQ-WPX-CW",
ModeCategory.Phone => "CQ-WPX-SSB",
_ => "CQ-WPX-RTTY",
};
public IReadOnlyList<ExchangeField> ExchangeFields =>
[
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
];
public IReadOnlyList<string> MultiplierNames => ["Prefixes"];
public DupeScope DupeScope => DupeScope.PerBand;
public bool HasSerialNumbers => true;
public IReadOnlyList<ModeCategory> Modes => [mode];
public string SentExchangeFor(StationInfo me) => DefaultReport();
public int PointsFor(QsoContext qso)
{
bool highBand = qso.Qso.Frequency >= LowBandLimit;
bool rtty = mode == ModeCategory.Digital;
if (qso.IsSameCountry)
{
return rtty ? (highBand ? 1 : 2) : 1;
}
if (!qso.IsSameContinent)
{
return highBand ? 3 : 6;
}
if (rtty || qso.Continent == "NA")
{
return highBand ? 2 : 4;
}
return highBand ? 1 : 2;
}
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
{
string? prefix = qso.Qso.Call.WpxPrefix();
return prefix is null ? [] : [new Multiplier(1, prefix, "")];
}
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
new(
[new CabrilloField(qso.SentReport, 3), new CabrilloField($"{qso.SentNumber:0000}", 6)],
[new CabrilloField(qso.ReceivedReport, 3), new CabrilloField($"{qso.ReceivedNumber:0000}", 6)]);
private string ModeLabel() => mode switch
{
ModeCategory.Cw => "CW",
ModeCategory.Phone => "SSB",
_ => "RTTY",
};
private string DefaultReport() => mode == ModeCategory.Phone ? "59" : "599";
}

View File

@@ -0,0 +1,34 @@
namespace Nonemm.Contests;
/// The running score: contacts, points and how many of each multiplier.
public sealed class ScoreTally
{
private readonly Dictionary<int, int> multipliers = [];
public int Qsos { get; private set; }
public int Points { get; private set; }
public int MultiplierCount(int index) =>
multipliers.TryGetValue(index, out int count) ? count : 0;
public int TotalMultipliers => multipliers.Values.Sum();
public void AddQso(int points)
{
Qsos++;
Points += points;
}
public void RemoveQso(int points)
{
Qsos--;
Points -= points;
}
public void AddMultiplier(int index) =>
multipliers[index] = MultiplierCount(index) + 1;
public void RemoveMultiplier(int index) =>
multipliers[index] = MultiplierCount(index) - 1;
}

View File

@@ -0,0 +1,14 @@
namespace Nonemm.Contests;
/// What the log says about a contact: whether it counts, what it scores, and
/// which multipliers it brings in. Every window that colours a station reads
/// this, so one station cannot look worked in one place and new in another.
public sealed record Verdict(
bool IsDupe,
int Points,
IReadOnlyList<Multiplier> NewMultipliers)
{
public static readonly Verdict Dupe = new(true, 0, []);
public bool IsNewMultiplier => NewMultipliers.Count > 0;
}