diff --git a/README.md b/README.md index e60bc02..e41fa28 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,8 @@ Along the bottom of the entry window is N1MM's status bar, in three parts: so IARU shows the ITU zone and CQ WW the CQ zone. When something has just happened — a contact logged, a call stacked, a download finished — that stands there instead until the next keystroke. +- Then the band change timer, for a contest that asks a station to stay on a + band for so long after moving to it. - In the middle, the contacts and multipliers: `10/7/8` is ten contacts, seven zones and eight countries, one number per multiplier the contest counts. It reads `No Score` before the first contact, as N1MM's does. @@ -445,8 +447,15 @@ 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. +`SingleOpCountableBandChange` and the rest of that family. + +`SOBandChangeTimerDuration` and `MOBandChangeTimerDuration` say how long a +station has to stay on a band it has moved to, and the countdown stands in the +status bar while it runs, reading `band 04:12` — `band/mode 04:12` for a contest +that counts a change of mode as well. It is there only for a contest that asks +for a stay and for the category the contest counts band changes for, so most +contests show nothing. The stay starts at the minute after the contact that +changed band, which is where N1MM starts it. ### Radios diff --git a/docs/unfinished.md b/docs/unfinished.md index 3bf220e..16a80c9 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -111,9 +111,7 @@ 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. `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. +Cabrillo one. `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 diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index 7786e85..1bbf342 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -123,16 +123,18 @@ - + - + - + - diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index d8b3e33..4743c13 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -747,6 +747,7 @@ public sealed partial class EntryWindow : Window ShowLights(); ShowScore(); + ShowBandTimer(); StatusText.Text = message.Length > 0 ? message : CountryLine(); Verdict? verdict = Logging.Verdict(); @@ -914,7 +915,31 @@ public sealed partial class EntryWindow : Window ? $"sending {Logging?.SentNumber}" : Verdicts.Describe(verdict); - private void UpdateClock() => ClockText.Text = DateTime.UtcNow.ToString("HH:mm:ss") + "Z"; + private void UpdateClock() + { + ClockText.Text = DateTime.UtcNow.ToString("HH:mm:ss") + "Z"; + ShowBandTimer(); + } + + /// The countdown N1MM puts in its info window: how long the station still + /// has to stay on the band it last moved to. It is here only while the + /// contest asks for a stay and the entry's category is one the contest + /// counts band changes for, so most contests show nothing. + private void ShowBandTimer() + { + BandChangeRules rules = Logging is null + ? BandChangeRules.None + : Logging.Contest.BandChangesFor(Logging.Log.Entry); + if (!rules.HasStayTimer) + { + BandTimerText.Text = ""; + return; + } + TimeSpan left = rules.StayLeft(Logging!.Log.Qsos, DateTime.UtcNow); + string what = rules.CountsModeChanges ? "band/mode" : "band"; + BandTimerText.Text = $"{what} {left.Minutes + (left.Hours * 60):00}:{left.Seconds:00}"; + BandTimerText.Opacity = left > TimeSpan.Zero ? 1.0 : 0.5; + } /// What just happened, in the left of the status bar. private void Status(string text) diff --git a/src/Nonemm.Contests/BandChangeRules.cs b/src/Nonemm.Contests/BandChangeRules.cs index 7d37465..f7d00e8 100644 --- a/src/Nonemm.Contests/BandChangeRules.cs +++ b/src/Nonemm.Contests/BandChangeRules.cs @@ -8,6 +8,10 @@ namespace Nonemm.Contests; /// `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, @@ -20,6 +24,9 @@ public sealed record BandChangeRules( 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) @@ -36,6 +43,42 @@ public sealed record BandChangeRules( 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); diff --git a/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs b/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs index 53fb4df..36fd52e 100644 --- a/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs +++ b/tests/Nonemm.Contests.Tests/BandChangeRulesTests.cs @@ -8,10 +8,10 @@ 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) => + private static Qso At(int minutes, double kilohertz, Mode? mode = null, int seconds = 0) => TestLog.Contact("OM3XYZ", kilohertz, mode: mode) with { - TimestampUtc = Start.AddMinutes(minutes), + TimestampUtc = Start.AddMinutes(minutes).AddSeconds(seconds), }; [Fact] @@ -59,6 +59,43 @@ public class BandChangeRulesTests [At(0, 14_025), At(5, 14_250, Modes.Usb)], Start.AddMinutes(10))); + /// The stay runs from the minute after the contact that changed band, so a + /// change at 12:05:40 with a ten-minute stay has five minutes left at + /// 12:11. N1MM starts it the same way. + [Fact] + public void TheStayCountsDownFromTheMinuteAfterTheChange() + { + BandChangeRules rules = new(8, MinimumStay: TimeSpan.FromMinutes(10)); + + Assert.Equal( + TimeSpan.FromMinutes(5), + rules.StayLeft([At(0, 14_025), At(5, 7_040, seconds: 40)], Start.AddMinutes(11))); + } + + [Fact] + public void TheStayIsUpOnceTheTimeHasPassed() => + Assert.Equal( + TimeSpan.Zero, + new BandChangeRules(8, MinimumStay: TimeSpan.FromMinutes(10)).StayLeft( + [At(0, 14_025), At(5, 7_040)], + Start.AddMinutes(30))); + + /// A log that has stayed on one band has nothing to count down. + [Fact] + public void NothingCountsDownUntilTheStationChangesBand() => + Assert.Equal( + TimeSpan.Zero, + new BandChangeRules(8, MinimumStay: TimeSpan.FromMinutes(10)).StayLeft( + [At(0, 14_025), At(5, 14_030)], + Start.AddMinutes(6))); + + [Fact] + public void AContestThatAsksForNoStayHasNoTimer() + { + Assert.False(new BandChangeRules(8).HasStayTimer); + Assert.True(new BandChangeRules(8, MinimumStay: TimeSpan.FromMinutes(10)).HasStayTimer); + } + [Fact] public void AContestWithNoLimitCountsNothing() => Assert.False(BandChangeRules.None.IsCounted); }