Files
Nonemm/src/Nonemm.Contests/BandChangeRules.cs
T
erikbrockoandClaude Opus 5 7ba0b44fc1 Take serial numbers from a pool the whole entry shares
Each station worked out its next number as the highest in its log plus one,
so two operators typing at the same moment both sent the same number and
one of the two contacts was wrong in the sponsor's log.

SerialNumberPool holds the highest number taken per band, which is N1MM's
QSONumbers array. A number is taken at the first character of the callsign,
as N1MM takes it, and ReserveNr tells the other stations before the contact
is worked. A number at or below one this station has used is answered with
the whole pool, N1MM's QSONRS, and the station that is behind counts on from
there. The pool also goes out when a contest is opened and when the numbers
are started again.

Contest.NumbersByBand says whether the numbers run per band or as one
sequence for the station. CQ WPX runs one per band for a multi-two and a
multi-multi; every other contest here runs one sequence. The band index in
the message is N1MM's, so the array lines up with theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
2026-09-10 11:56:49 +00:00

121 lines
4.8 KiB
C#

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.
///
/// `MinimumStay` is how long the station has to stay on a band it has just
/// moved to. Both the count and the stay are worked out from the log, as
/// N1MM's are: the contact that lands on another band is the change.
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);
/// How long a multi-operator entry with one transmitter has to stay on a
/// band. It is the ten-minute rule, and it is N1MM's fallback for every
/// contest that does not name a number of its own.
public static readonly TimeSpan MultiOneStay = TimeSpan.FromMinutes(10);
/// What a contest allows an entry in this category, before the contest has
/// its say. These are N1MM's defaults, from
/// `ContestInstance.BandChangeTimerDuration` and `BandChangeCountMax`:
///
/// | Category | Changes counted | Stay |
/// |---|---|---|
/// | single operator | no | none |
/// | multi-operator, one transmitter | no | ten minutes |
/// | multi-operator, two transmitters | no | ten minutes |
/// | multi-operator, more | no | none |
///
/// The count is off by default because N1MM's own fallback for it is zero:
/// the contests that cap band changes per hour name the cap themselves, and
/// a contest class that does so overrides `Contest.BandChangesFor`. The
/// stay is the other way round — ten minutes is the fallback, and a contest
/// with no rule of its own gets it.
///
/// A station with a transmitter per band changes band by moving to another
/// radio, so nothing is counted for it.
public static BandChangeRules ForCategory(ContestEntry entry) =>
EntryCategories.IsMultiOne(entry) || EntryCategories.IsMultiTwo(entry)
? None with { MinimumStay = MultiOneStay }
: None;
public bool IsCounted => Max > 0;
/// True for a contest that says how long a station has to stay on a band.
public bool HasStayTimer => MinimumStay > TimeSpan.Zero;
/// 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;
}
/// How much of the stay on this band is left. Zero once the time is up,
/// and zero for a log that has not changed band yet.
///
/// The stay is counted from the minute after the contact that changed
/// band, which is where N1MM starts it: a contact at 12:00:40 puts the
/// station on the band from 12:01.
public TimeSpan StayLeft(IEnumerable<Qso> qsos, DateTime now)
{
if (!HasStayTimer || LastChange(qsos) is not { } changed)
{
return TimeSpan.Zero;
}
TimeSpan left = MinimumStay - (now - NextMinute(changed));
return left > TimeSpan.Zero ? left : TimeSpan.Zero;
}
/// When the last contact that moved the station to another band was, or
/// null while every contact has been on one band.
private DateTime? LastChange(IEnumerable<Qso> qsos)
{
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
DateTime? found = null;
for (int at = 1; at < ordered.Count; at++)
{
if (IsChange(ordered[at - 1], ordered[at]))
{
found = ordered[at].TimestampUtc;
}
}
return found;
}
private static DateTime NextMinute(DateTime when) =>
new DateTime(when.Year, when.Month, when.Day, when.Hour, when.Minute, 0, when.Kind)
.AddMinutes(1);
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);
}