Act on the .udc session, off-time and band-change settings
Four settings were read by nothing. MultipleSessions = 0000/30 runs the contest in half-hour sessions from 00:00 UTC. The session is part of the dupe key, so a station worked in an earlier one may be worked again, and ResetMultsEverySession and ResetSNEverySession start the multipliers and the serial numbers over. DupeQSOMinutesAgo is the other way back to a station: after so many minutes rather than in the next session. The log holds the time of the last contact per dupe key instead of only the key, and the setting's IgnoreBand and ThisMode decide the scope, because a file that uses it turns DupeType off. MinimumOffTime drives the time on and time off the score summary now shows under the score, and beside them are the band changes made against the allowance for a contest that limits them. N1MM's countdown for the minutes you have to stay on a new band needs its info window and is not here. UdcFile.Flag now takes 1 and 0 as well as True and False, which is what these settings are written as and what N1MM reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
20
README.md
20
README.md
@@ -428,6 +428,26 @@ a `CQZone` column fills that in.
|
||||
A spot carries no mode either. Stations are judged in the mode the radio is in,
|
||||
so in a mixed-mode contest the answer follows the operator.
|
||||
|
||||
### Sessions, time off and band changes
|
||||
|
||||
A contest can be run in sessions, and a `.udc` file says so with
|
||||
`MultipleSessions = 0000/30` — sessions of thirty minutes from 00:00 UTC. A
|
||||
station worked in an earlier session may be worked again, and
|
||||
`ResetMultsEverySession` and `ResetSNEverySession` start the multipliers and the
|
||||
serial numbers over as well.
|
||||
|
||||
`DupeQSOMinutesAgo` is the other way a contest lets a station be worked again:
|
||||
after so many minutes rather than in the next session. Its `IgnoreBand` and
|
||||
`ThisMode` settings decide whether the earlier contact is looked for on this
|
||||
band only and in this mode only.
|
||||
|
||||
The score summary shows the time on and the time off under the score. A break
|
||||
counts as time off once it is as long as the contest's `MinimumOffTime`, which
|
||||
is 30 minutes unless the file says otherwise. Next to them, for a contest that
|
||||
limits band changes, are the changes made and the allowance —
|
||||
`SingleOpCountableBandChange` and the rest of that family. N1MM's countdown for
|
||||
the minutes you have to stay on a new band is not here.
|
||||
|
||||
### Radios
|
||||
|
||||
**Config → Radios** takes a `rigctld` address per radio. Each radio needs its
|
||||
|
||||
@@ -111,7 +111,9 @@ with the CW and SSB running of the contest.
|
||||
`.udc` settings that are still passed over. `MultiplierBands` and
|
||||
`QsoErrorString`, which are about the windows rather than the score.
|
||||
`GenericPrintString`, which is the layout of a printed log rather than a
|
||||
Cabrillo one.
|
||||
Cabrillo one. `SOBandChangeTimerDuration` and `MOBandChangeTimerDuration`, the
|
||||
minutes a station has to stay on a new band, which N1MM shows as a countdown in
|
||||
its info window; this program has no info window and no countdown.
|
||||
|
||||
`MultWindowType` is read as far as the score goes: it names the list a section
|
||||
multiplier is checked against. Four of N1MM's lists are held here — the ARRL
|
||||
@@ -131,11 +133,7 @@ alone, which writes only half of a two-box exchange. Layout 6, the ARRL RTTY
|
||||
Roundup line, is not read, and a file asking for it gets the default line; no
|
||||
published `.udc` file asks for it, because the Roundup has a contest class of
|
||||
its own. `CabrilloFormat = 0` means the sponsor takes no Cabrillo log at all,
|
||||
and nothing here stops the operator writing one. The
|
||||
session, off-time and band-change settings
|
||||
(`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family,
|
||||
`DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged
|
||||
as one long session.
|
||||
and nothing here stops the operator writing one.
|
||||
|
||||
N1MM's `.udc` format has no way to say that the exchange itself differs by the
|
||||
other station's country. OK/OM DX is written in code for that reason — it asks
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -59,9 +60,32 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
||||
session.Logging.Log.Tally.TotalMultipliers.ToString(),
|
||||
header: true);
|
||||
|
||||
TotalText.Text = $"Claimed score {session.Logging.Log.TotalScore:N0}";
|
||||
TimeSpan on = OperatingTime.On(
|
||||
session.Logging.Log.Qsos,
|
||||
session.Logging.Contest.MinimumOffTime);
|
||||
TimeSpan off = OperatingTime.Off(
|
||||
session.Logging.Log.Qsos,
|
||||
session.Logging.Contest.MinimumOffTime);
|
||||
TotalText.Text =
|
||||
$"Claimed score {session.Logging.Log.TotalScore:N0}"
|
||||
+ $" on {Hours(on)} off {Hours(off)}"
|
||||
+ BandChanges();
|
||||
}
|
||||
|
||||
/// The band changes made and the allowance, for a contest that limits
|
||||
/// them, and nothing at all for the contests that do not.
|
||||
private string BandChanges()
|
||||
{
|
||||
BandChangeRules rules = session.Logging!.Contest.BandChangesFor(session.Logging.Log.Entry);
|
||||
return rules.IsCounted
|
||||
? $" band changes {rules.CountedIn(session.Logging.Log.Qsos, DateTime.UtcNow)}/{rules.Max}"
|
||||
: "";
|
||||
}
|
||||
|
||||
/// Hours and minutes, which is how a sponsor asks for operating time.
|
||||
private static string Hours(TimeSpan time) =>
|
||||
$"{(int)time.TotalHours}:{time.Minutes:00}";
|
||||
|
||||
private static int MultiplierCount(Qso qso) =>
|
||||
(qso.IsMultiplier1 ? 1 : 0) + (qso.IsMultiplier2 ? 1 : 0) + (qso.IsMultiplier3 ? 1 : 0);
|
||||
|
||||
|
||||
48
src/Nonemm.Contests/BandChangeRules.cs
Normal file
48
src/Nonemm.Contests/BandChangeRules.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// What a contest allows a station in the way of band changes, and how to
|
||||
/// count the ones already made. N1MM shows the same count in its info window.
|
||||
///
|
||||
/// `Max` is how many changes the entry is allowed. The window the count is
|
||||
/// taken over is the clock hour, unless `PerContest` counts the whole contest
|
||||
/// or `PerPeriod` counts the hour up to now.
|
||||
public sealed record BandChangeRules(
|
||||
int Max,
|
||||
bool PerContest = false,
|
||||
bool PerPeriod = false,
|
||||
bool CountsModeChanges = false,
|
||||
TimeSpan MinimumStay = default)
|
||||
{
|
||||
/// A contest that does not limit band changes at all.
|
||||
public static readonly BandChangeRules None = new(0);
|
||||
|
||||
public bool IsCounted => Max > 0;
|
||||
|
||||
/// How many band changes fall in the window that ends at `now`. The first
|
||||
/// contact of the log is not a change.
|
||||
public int CountedIn(IEnumerable<Qso> qsos, DateTime now)
|
||||
{
|
||||
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
|
||||
int changes = 0;
|
||||
for (int at = 1; at < ordered.Count; at++)
|
||||
{
|
||||
if (IsChange(ordered[at - 1], ordered[at]) && IsInWindow(ordered[at].TimestampUtc, now))
|
||||
{
|
||||
changes++;
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
private bool IsChange(Qso before, Qso after) =>
|
||||
before.Band != after.Band
|
||||
|| (CountsModeChanges && before.Mode.Category != after.Mode.Category);
|
||||
|
||||
private bool IsInWindow(DateTime when, DateTime now) =>
|
||||
PerContest
|
||||
|| (PerPeriod
|
||||
? when <= now && now - when < TimeSpan.FromHours(1)
|
||||
: when.Date == now.Date && when.Hour == now.Hour);
|
||||
}
|
||||
@@ -60,6 +60,29 @@ public interface Contest
|
||||
/// the message goes out. Only the BARTG RTTY contest asks for it.
|
||||
bool StampsSentTime => false;
|
||||
|
||||
/// How long a station stays a dupe. Null for the usual rule, where a
|
||||
/// station worked once cannot be worked again for the rest of the contest.
|
||||
/// N1MM's `DupeQSOMinutesAgo`.
|
||||
TimeSpan? DupeWindow => null;
|
||||
|
||||
/// Which session of the contest a time belongs to. A contest run in
|
||||
/// sessions allows a station to be worked again in each of them, and can
|
||||
/// start its multipliers and its serial numbers over as well. Everything
|
||||
/// is session 0 for a contest run in one go.
|
||||
int SessionOf(DateTime utc) => 0;
|
||||
|
||||
bool ResetsMultipliersEachSession => false;
|
||||
|
||||
bool ResetsSerialNumbersEachSession => false;
|
||||
|
||||
/// How many band changes the entry may make, and over what stretch of time
|
||||
/// they are counted. Most contests do not limit them.
|
||||
BandChangeRules BandChangesFor(ContestEntry entry) => BandChangeRules.None;
|
||||
|
||||
/// How long a gap between contacts has to be before it counts as time off.
|
||||
/// N1MM asks each contest and takes 30 minutes when it says nothing.
|
||||
TimeSpan MinimumOffTime => TimeSpan.FromMinutes(30);
|
||||
|
||||
/// True for a contest whose log carries QTC traffic as rows of its own.
|
||||
/// The log window then shows the series they belong to.
|
||||
bool HasQtcTraffic => false;
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class ContestLog
|
||||
private readonly StationInfo me;
|
||||
private readonly CountryFile? countries;
|
||||
private readonly List<Qso> qsos = [];
|
||||
private readonly HashSet<string> workedKeys = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, DateTime> workedKeys = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
|
||||
private ScoreTally tally = new();
|
||||
|
||||
@@ -52,14 +52,14 @@ public sealed class ContestLog
|
||||
{
|
||||
return new Verdict(false, contest.PointsFor(context), []);
|
||||
}
|
||||
if (contest.DupeScope != DupeScope.Never && workedKeys.Contains(DupeKey(candidate)))
|
||||
if (IsWorked(candidate))
|
||||
{
|
||||
return Verdict.Dupe;
|
||||
}
|
||||
List<Multiplier> newOnes = [];
|
||||
foreach (Multiplier multiplier in contest.MultipliersFor(context))
|
||||
{
|
||||
if (!claimedMultipliers.Contains(MultiplierKey(multiplier)))
|
||||
if (!claimedMultipliers.Contains(MultiplierKey(multiplier, candidate)))
|
||||
{
|
||||
newOnes.Add(multiplier);
|
||||
}
|
||||
@@ -114,10 +114,22 @@ public sealed class ContestLog
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public bool IsWorked(Qso candidate) =>
|
||||
contest.DupeScope != DupeScope.Never
|
||||
&& contest.IsContact(candidate)
|
||||
&& workedKeys.Contains(DupeKey(candidate));
|
||||
/// A station worked before, under the contest's dupe rule. A contest with
|
||||
/// a dupe window — N1MM's `DupeQSOMinutesAgo` — lets the same station be
|
||||
/// worked again once the window has passed.
|
||||
public bool IsWorked(Qso candidate)
|
||||
{
|
||||
if (contest.DupeScope == DupeScope.Never || !contest.IsContact(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!workedKeys.TryGetValue(DupeKey(candidate), out DateTime last))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return contest.DupeWindow is not { } window
|
||||
|| (candidate.TimestampUtc - last).Duration() < window;
|
||||
}
|
||||
|
||||
/// Every contact with this call, newest first.
|
||||
public IReadOnlyList<Qso> WorkedBefore(string call) =>
|
||||
@@ -159,10 +171,10 @@ public sealed class ContestLog
|
||||
{
|
||||
return;
|
||||
}
|
||||
workedKeys.Add(DupeKey(qso));
|
||||
workedKeys[DupeKey(qso)] = qso.TimestampUtc;
|
||||
foreach (Multiplier multiplier in verdict.NewMultipliers)
|
||||
{
|
||||
claimedMultipliers.Add(MultiplierKey(multiplier));
|
||||
claimedMultipliers.Add(MultiplierKey(multiplier, qso));
|
||||
tally.AddMultiplier(multiplier);
|
||||
}
|
||||
}
|
||||
@@ -197,9 +209,13 @@ public sealed class ContestLog
|
||||
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
|
||||
_ => call,
|
||||
};
|
||||
// a station worked in an earlier session may be worked again
|
||||
key = $"{key}|{contest.SessionOf(qso.TimestampUtc)}";
|
||||
return extra.Length == 0 ? key : $"{key}|{extra}";
|
||||
}
|
||||
|
||||
private static string MultiplierKey(Multiplier multiplier) =>
|
||||
$"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
|
||||
private string MultiplierKey(Multiplier multiplier, Qso qso) =>
|
||||
contest.ResetsMultipliersEachSession
|
||||
? $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}|{contest.SessionOf(qso.TimestampUtc)}"
|
||||
: $"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
|
||||
}
|
||||
|
||||
48
src/Nonemm.Contests/OperatingTime.cs
Normal file
48
src/Nonemm.Contests/OperatingTime.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// Time on and time off, which the sponsors of a contest with a rest rule ask
|
||||
/// the entrant to declare. A gap between two contacts counts as time off when
|
||||
/// it is at least the contest's `MinimumOffTime`; anything shorter is time
|
||||
/// spent operating. This is how N1MM's off-time window reads a log.
|
||||
public static class OperatingTime
|
||||
{
|
||||
/// The gaps that count as time off, in the order they happened.
|
||||
public static IReadOnlyList<(DateTime From, DateTime To)> Breaks(
|
||||
IEnumerable<Qso> qsos,
|
||||
TimeSpan minimum)
|
||||
{
|
||||
List<(DateTime, DateTime)> found = [];
|
||||
DateTime? last = null;
|
||||
foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc))
|
||||
{
|
||||
if (last is { } before && qso.TimestampUtc - before >= minimum)
|
||||
{
|
||||
found.Add((before, qso.TimestampUtc));
|
||||
}
|
||||
last = qso.TimestampUtc;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static TimeSpan Off(IEnumerable<Qso> qsos, TimeSpan minimum)
|
||||
{
|
||||
TimeSpan total = TimeSpan.Zero;
|
||||
foreach ((DateTime from, DateTime to) in Breaks(qsos, minimum))
|
||||
{
|
||||
total += to - from;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// From the first contact to the last, less the time off. A log of one
|
||||
/// contact or none is no time at all.
|
||||
public static TimeSpan On(IEnumerable<Qso> qsos, TimeSpan minimum)
|
||||
{
|
||||
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
|
||||
return ordered.Count < 2
|
||||
? TimeSpan.Zero
|
||||
: ordered[^1].TimestampUtc - ordered[0].TimestampUtc - Off(ordered, minimum);
|
||||
}
|
||||
}
|
||||
48
src/Nonemm.Contests/Udc/UdcDupeWindow.cs
Normal file
48
src/Nonemm.Contests/Udc/UdcDupeWindow.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// `DupeQSOMinutesAgo = 10, True, False, True, False`: a station worked less
|
||||
/// than ten minutes ago is a dupe, and after that it can be worked again. The
|
||||
/// settings after the minutes are N1MM's, and two of them decide how wide to
|
||||
/// look for that earlier contact:
|
||||
///
|
||||
/// | Setting | Default | What it does here |
|
||||
/// |---|---|---|
|
||||
/// | CompareToSecond | True | not read: the times are compared to the second |
|
||||
/// | IgnoreBand | False | true looks on every band rather than this one |
|
||||
/// | InfoStatus | True | not read: it turns N1MM's info window message on |
|
||||
/// | ThisMode | False | true looks only at contacts in the same mode |
|
||||
///
|
||||
/// A file that sets this also sets `DupeType = 4`, which is N1MM's way of
|
||||
/// saying the time decides on its own, so the scope here comes from these two
|
||||
/// settings rather than from `DupeType`.
|
||||
public sealed class UdcDupeWindow
|
||||
{
|
||||
private readonly int minutes;
|
||||
private readonly bool ignoresBand;
|
||||
private readonly bool thisModeOnly;
|
||||
|
||||
public UdcDupeWindow(string setting)
|
||||
{
|
||||
string[] parts = setting.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0 || !int.TryParse(parts[0], out minutes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
ignoresBand = Flag(parts, 2, false);
|
||||
thisModeOnly = Flag(parts, 4, false);
|
||||
}
|
||||
|
||||
public bool IsDefined => minutes > 0;
|
||||
|
||||
public TimeSpan? Window => IsDefined ? TimeSpan.FromMinutes(minutes) : null;
|
||||
|
||||
public DupeScope Scope =>
|
||||
ignoresBand
|
||||
? thisModeOnly ? DupeScope.PerMode : DupeScope.Once
|
||||
: thisModeOnly ? DupeScope.PerBandAndMode : DupeScope.PerBand;
|
||||
|
||||
private static bool Flag(string[] parts, int at, bool absent) =>
|
||||
parts.Length > at && parts[at].Length > 0
|
||||
? parts[at].Equals("TRUE", StringComparison.OrdinalIgnoreCase)
|
||||
: absent;
|
||||
}
|
||||
@@ -55,8 +55,17 @@ public sealed class UdcFile
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
public bool Flag(string key, bool fallback) =>
|
||||
bool.TryParse(Lookup(key), out bool value) ? value : fallback;
|
||||
/// A yes-or-no setting. Files write these as `True`/`False` and as `1`/`0`,
|
||||
/// and N1MM takes both.
|
||||
public bool Flag(string key, bool fallback)
|
||||
{
|
||||
string? value = Lookup(key)?.Trim();
|
||||
if (bool.TryParse(value, out bool said))
|
||||
{
|
||||
return said;
|
||||
}
|
||||
return int.TryParse(value, out int number) ? number != 0 : fallback;
|
||||
}
|
||||
|
||||
/// A comma-separated value with the spaces trimmed off each part.
|
||||
public IReadOnlyList<string> List(string key) =>
|
||||
|
||||
66
src/Nonemm.Contests/Udc/UdcSessions.cs
Normal file
66
src/Nonemm.Contests/Udc/UdcSessions.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// `MultipleSessions = 0000/30`: the contest runs in sessions, the first one
|
||||
/// starting at 00:00 UTC and each lasting 30 minutes. A station worked in an
|
||||
/// earlier session may be worked again, and `ResetMultsEverySession` and
|
||||
/// `ResetSNEverySession` say whether the multipliers and the serial numbers
|
||||
/// start over as well.
|
||||
///
|
||||
/// N1MM writes the length as minutes up to two digits and as hours and minutes
|
||||
/// above that: `30` is half an hour, `200` is two hours. The shortest session
|
||||
/// N1MM takes is ten minutes.
|
||||
public sealed class UdcSessions
|
||||
{
|
||||
private static readonly TimeSpan Shortest = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly TimeSpan start;
|
||||
private readonly TimeSpan length;
|
||||
|
||||
public UdcSessions(UdcFile file)
|
||||
{
|
||||
ResetsMultipliers = file.Flag("ResetMultsEverySession", false);
|
||||
ResetsSerialNumbers = file.Flag("ResetSNEverySession", false);
|
||||
string[] parts = file.Text("MultipleSessions").Split('/', StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TimeSpan? first = HoursAndMinutes(parts[0]);
|
||||
TimeSpan? each = HoursAndMinutes(parts[1]);
|
||||
if (first is null || each is null || each < Shortest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
start = first.Value;
|
||||
length = each.Value;
|
||||
IsDefined = true;
|
||||
}
|
||||
|
||||
public bool IsDefined { get; }
|
||||
|
||||
public bool ResetsMultipliers { get; }
|
||||
|
||||
public bool ResetsSerialNumbers { get; }
|
||||
|
||||
/// Which session a contact belongs to. The sessions are counted off a
|
||||
/// fixed date so that two days of a contest never share a number, which is
|
||||
/// what N1MM's own stepping from the start time gives. Every contact is in
|
||||
/// session 0 for a contest without sessions.
|
||||
public int SessionOf(DateTime utc) =>
|
||||
IsDefined ? (int)((utc - (DateTime.UnixEpoch + start)).Ticks / length.Ticks) : 0;
|
||||
|
||||
/// `30` is thirty minutes, `200` two hours, `0000` midnight. A value that
|
||||
/// is not a number at all leaves the setting off.
|
||||
private static TimeSpan? HoursAndMinutes(string value)
|
||||
{
|
||||
if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int number))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value.Length > 2
|
||||
? TimeSpan.FromMinutes((number / 100 * 60) + (number % 100))
|
||||
: TimeSpan.FromMinutes(number);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ public sealed class UserDefinedContest : Contest
|
||||
private readonly UdcCabrillo cabrillo;
|
||||
private readonly UdcCabrilloFormat cabrilloFormat;
|
||||
private readonly UdcBonusCalls bonusCalls;
|
||||
private readonly UdcSessions sessions;
|
||||
private readonly UdcDupeWindow dupeWindow;
|
||||
|
||||
/// `supportFolder` is where `BonusPoints2` reads its list of callsigns
|
||||
/// from, which is N1MM's support-files folder. Without it that setting is
|
||||
@@ -33,6 +35,8 @@ public sealed class UserDefinedContest : Contest
|
||||
cabrillo = new UdcCabrillo(file.Text("CabrilloString"));
|
||||
cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1));
|
||||
bonusCalls = new UdcBonusCalls(file.Text("BonusPoints2"), supportFolder);
|
||||
sessions = new UdcSessions(file);
|
||||
dupeWindow = new UdcDupeWindow(file.Text("DupeQSOMinutesAgo"));
|
||||
}
|
||||
|
||||
public static UserDefinedContest Load(string path) =>
|
||||
@@ -55,13 +59,46 @@ public sealed class UserDefinedContest : Contest
|
||||
.Where(name => name.Length > 0)
|
||||
.ToList();
|
||||
|
||||
public DupeScope DupeScope => file.Number("DupeType", 2) switch
|
||||
/// `DupeQSOMinutesAgo` decides the scope itself when it is set, because a
|
||||
/// file that uses it turns `DupeType` off.
|
||||
public DupeScope DupeScope => dupeWindow.IsDefined
|
||||
? dupeWindow.Scope
|
||||
: file.Number("DupeType", 2) switch
|
||||
{
|
||||
1 => DupeScope.Once,
|
||||
3 => DupeScope.PerBandAndMode,
|
||||
4 => DupeScope.Never,
|
||||
_ => DupeScope.PerBand,
|
||||
};
|
||||
|
||||
public TimeSpan? DupeWindow => dupeWindow.Window;
|
||||
|
||||
public int SessionOf(DateTime utc) => sessions.SessionOf(utc);
|
||||
|
||||
public bool ResetsMultipliersEachSession => sessions.ResetsMultipliers;
|
||||
|
||||
public bool ResetsSerialNumbersEachSession => sessions.ResetsSerialNumbers;
|
||||
|
||||
public TimeSpan MinimumOffTime => TimeSpan.FromMinutes(file.Number("MinimumOffTime", 30));
|
||||
|
||||
/// The `…BandChange…` family. A multi-op entry reads the `MultiOp…`
|
||||
/// settings and everyone else the `SingleOp…` ones, which is the split
|
||||
/// N1MM makes.
|
||||
public BandChangeRules BandChangesFor(ContestEntry entry)
|
||||
{
|
||||
1 => DupeScope.Once,
|
||||
3 => DupeScope.PerBandAndMode,
|
||||
4 => DupeScope.Never,
|
||||
_ => DupeScope.PerBand,
|
||||
};
|
||||
bool multiOp = entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase);
|
||||
string side = multiOp ? "MultiOp" : "SingleOp";
|
||||
string max = multiOp ? "MOBandChangeCountMax" : "SOBandChangeCountMax";
|
||||
string stay = multiOp ? "MOBandChangeTimerDuration" : "SOBandChangeTimerDuration";
|
||||
return file.Flag($"{side}CountableBandChange", false)
|
||||
? new BandChangeRules(
|
||||
file.Number(max, 0),
|
||||
file.Flag("CountBandChangesPerContest", false),
|
||||
file.Flag("CountBandChangesPerPeriod", false),
|
||||
file.Flag("CountBandOrModeChange", false),
|
||||
TimeSpan.FromMinutes(file.Number(stay, 0)))
|
||||
: BandChangeRules.None;
|
||||
}
|
||||
|
||||
/// `DupeSqlString` names one more field that makes a contact distinct: a
|
||||
/// station worked again with a different section, exchange, mode or grid
|
||||
|
||||
@@ -180,6 +180,15 @@ public sealed class ContestSession
|
||||
return edit;
|
||||
}
|
||||
|
||||
private int NextSentNumber() =>
|
||||
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
|
||||
/// The next serial number to send. A contest whose `ResetSNEverySession`
|
||||
/// is set starts over in each session, so only the contacts of the session
|
||||
/// we are in count.
|
||||
private int NextSentNumber()
|
||||
{
|
||||
IEnumerable<Qso> counted = Contest.ResetsSerialNumbersEachSession
|
||||
? Log.Qsos.Where(q =>
|
||||
Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow))
|
||||
: Log.Qsos;
|
||||
return counted.Select(q => q.SentNumber).DefaultIfEmpty(0).Max() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
64
tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs
Normal file
64
tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
/// The `…BandChange…` settings: what counts as a change, and over what stretch
|
||||
/// of time the changes are counted.
|
||||
public class BandChangeRulesTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Qso At(int minutes, double kilohertz, Mode? mode = null) =>
|
||||
TestLog.Contact("OM3XYZ", kilohertz, mode: mode) with
|
||||
{
|
||||
TimestampUtc = Start.AddMinutes(minutes),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void EachMoveToAnotherBandIsOneChange() =>
|
||||
Assert.Equal(
|
||||
2,
|
||||
new BandChangeRules(8).CountedIn(
|
||||
[At(0, 14_025), At(5, 7_040), At(10, 7_045), At(15, 14_030)],
|
||||
Start.AddMinutes(20)));
|
||||
|
||||
/// The count is per clock hour unless the file says otherwise, so a change
|
||||
/// made in the hour before does not count against this one.
|
||||
[Fact]
|
||||
public void ChangesMadeInAnEarlierHourAreNotCounted() =>
|
||||
Assert.Equal(
|
||||
1,
|
||||
new BandChangeRules(8).CountedIn(
|
||||
[At(0, 14_025), At(5, 7_040), At(65, 14_025)],
|
||||
Start.AddMinutes(70)));
|
||||
|
||||
[Fact]
|
||||
public void CountingPerContestKeepsEveryChange() =>
|
||||
Assert.Equal(
|
||||
2,
|
||||
new BandChangeRules(8, PerContest: true).CountedIn(
|
||||
[At(0, 14_025), At(5, 7_040), At(65, 14_025)],
|
||||
Start.AddMinutes(70)));
|
||||
|
||||
/// `CountBandChangesPerPeriod` counts the hour up to now rather than the
|
||||
/// clock hour.
|
||||
[Fact]
|
||||
public void CountingPerPeriodLooksBackAnHour() =>
|
||||
Assert.Equal(
|
||||
1,
|
||||
new BandChangeRules(8, PerPeriod: true).CountedIn(
|
||||
[At(0, 14_025), At(5, 7_040), At(65, 14_025)],
|
||||
Start.AddMinutes(70)));
|
||||
|
||||
/// `CountBandOrModeChange` counts a move to another mode on the same band.
|
||||
[Fact]
|
||||
public void ModeChangesCountWhenTheContestSaysSo() =>
|
||||
Assert.Equal(
|
||||
1,
|
||||
new BandChangeRules(8, CountsModeChanges: true).CountedIn(
|
||||
[At(0, 14_025), At(5, 14_250, Modes.Usb)],
|
||||
Start.AddMinutes(10)));
|
||||
|
||||
[Fact]
|
||||
public void AContestWithNoLimitCountsNothing() => Assert.False(BandChangeRules.None.IsCounted);
|
||||
}
|
||||
35
tests/Nonemm.Contests.Tests/OperatingTimeTests.cs
Normal file
35
tests/Nonemm.Contests.Tests/OperatingTimeTests.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
/// Time on and time off, as a sponsor with a rest rule asks for it.
|
||||
public class OperatingTimeTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Qso At(int minutes) =>
|
||||
TestLog.Contact("OM3XYZ") with { TimestampUtc = Start.AddMinutes(minutes) };
|
||||
|
||||
[Fact]
|
||||
public void AGapAsLongAsTheMinimumIsTimeOff() =>
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMinutes(30),
|
||||
OperatingTime.Off([At(0), At(10), At(40), At(50)], TimeSpan.FromMinutes(30)));
|
||||
|
||||
[Fact]
|
||||
public void AShorterGapIsTimeOperating() =>
|
||||
Assert.Equal(
|
||||
TimeSpan.Zero,
|
||||
OperatingTime.Off([At(0), At(20), At(40)], TimeSpan.FromMinutes(30)));
|
||||
|
||||
/// The time from the first contact to the last, less the breaks.
|
||||
[Fact]
|
||||
public void TimeOnIsWhatIsLeftOfTheContest() =>
|
||||
Assert.Equal(
|
||||
TimeSpan.FromMinutes(20),
|
||||
OperatingTime.On([At(0), At(10), At(40), At(50)], TimeSpan.FromMinutes(30)));
|
||||
|
||||
[Fact]
|
||||
public void OneContactIsNoTimeAtAll() =>
|
||||
Assert.Equal(TimeSpan.Zero, OperatingTime.On([At(0)], TimeSpan.FromMinutes(30)));
|
||||
}
|
||||
@@ -292,6 +292,84 @@ public class UdcScoringTests
|
||||
.Judge(TestLog.Contact("OM3XYZ", section: section))
|
||||
.NewMultipliers.Count);
|
||||
|
||||
/// `DupeQSOMinutesAgo = 10` lets the same station be worked again ten
|
||||
/// minutes after the last contact, and not before.
|
||||
[Fact]
|
||||
public void ADupeWindowLetsAStationBeWorkedAgainLater()
|
||||
{
|
||||
UserDefinedContest contest = Contest("DupeType=4", "DupeQSOMinutesAgo=10");
|
||||
ContestLog log = LogFor(contest);
|
||||
DateTime first = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
||||
|
||||
Assert.True(log.Judge(TestLog.Contact("OM3XYZ") with
|
||||
{
|
||||
TimestampUtc = first.AddMinutes(9),
|
||||
}).IsDupe);
|
||||
Assert.False(log.Judge(TestLog.Contact("OM3XYZ") with
|
||||
{
|
||||
TimestampUtc = first.AddMinutes(11),
|
||||
}).IsDupe);
|
||||
}
|
||||
|
||||
/// `IgnoreBand` looks for the earlier contact on every band rather than
|
||||
/// this one, so the same station on another band is still a dupe.
|
||||
[Fact]
|
||||
public void ADupeWindowThatIgnoresTheBandCoversEveryBand()
|
||||
{
|
||||
UserDefinedContest contest = Contest("DupeType=4", "DupeQSOMinutesAgo=10, True, True");
|
||||
ContestLog log = LogFor(contest);
|
||||
DateTime first = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
||||
|
||||
Assert.True(log.Judge(TestLog.Contact("OM3XYZ", 7_040) with
|
||||
{
|
||||
TimestampUtc = first.AddMinutes(5),
|
||||
}).IsDupe);
|
||||
}
|
||||
|
||||
/// `MultipleSessions = 0000/30` runs the contest in half-hour sessions,
|
||||
/// and a station worked in one of them may be worked again in the next.
|
||||
[Fact]
|
||||
public void AStationWorkedInAnEarlierSessionIsNoDupe()
|
||||
{
|
||||
UserDefinedContest contest = Contest("MultipleSessions=0000/30");
|
||||
ContestLog log = LogFor(contest);
|
||||
DateTime first = new(2026, 5, 30, 12, 10, 0, DateTimeKind.Utc);
|
||||
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
||||
|
||||
Assert.True(log.Judge(TestLog.Contact("OM3XYZ") with
|
||||
{
|
||||
TimestampUtc = first.AddMinutes(10),
|
||||
}).IsDupe);
|
||||
Assert.False(log.Judge(TestLog.Contact("OM3XYZ") with
|
||||
{
|
||||
TimestampUtc = first.AddMinutes(25),
|
||||
}).IsDupe);
|
||||
}
|
||||
|
||||
/// `ResetMultsEverySession` counts the same multiplier again in the next
|
||||
/// session. Without it the multiplier stands for the whole contest.
|
||||
[Theory]
|
||||
[InlineData("ResetMultsEverySession=1", 1)]
|
||||
[InlineData("ResetMultsEverySession=0", 0)]
|
||||
public void MultipliersStartOverEachSessionWhenTheFileSaysSo(string setting, int expected)
|
||||
{
|
||||
UserDefinedContest contest = Contest(
|
||||
"MultipleSessions=0000/30",
|
||||
"MultSqlString=CountryPrefix",
|
||||
"IsMultPer=4",
|
||||
setting);
|
||||
ContestLog log = LogFor(contest);
|
||||
DateTime first = new(2026, 5, 30, 12, 10, 0, DateTimeKind.Utc);
|
||||
log.Add(TestLog.Contact("OM3XYZ") with { TimestampUtc = first });
|
||||
|
||||
Assert.Equal(
|
||||
expected,
|
||||
log.Judge(TestLog.Contact("OM5ZZZ") with { TimestampUtc = first.AddMinutes(25) })
|
||||
.NewMultipliers.Count);
|
||||
}
|
||||
|
||||
/// `BonusPoints2` names how much a station on a list is worth and the file
|
||||
/// the list is in. `+50` adds to what the contact scored.
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user