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) { if (!entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase)) { return None; } // N1MM reads the number of transmitters off the operator category on // Cabrillo 2.0 and off the transmitter category after it. Both are // checked, so an entry written either way is read bool one = Is(entry, "MULTI-ONE", "ONE"); bool two = Is(entry, "MULTI-TWO", "TWO"); return one || two ? None with { MinimumStay = MultiOneStay } : None; } private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) => entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase) || entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase); 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 qsos, DateTime now) { List 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 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 qsos) { List 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); }