From 2eeda451c301ef9190c4b403dcd1c1f1741c9fd3 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 31 Aug 2026 13:51:17 +0000 Subject: [PATCH] Count down the band change timer in the status bar SOBandChangeTimerDuration and MOBandChangeTimerDuration say how long a station has to stay on a band it has just moved to. Both were read by nothing: N1MM counts them down in its info window, which this program does not have. The countdown now stands in the entry window's status bar, next to the contacts and multipliers, ticking with the clock that is already there. BandChangeRules.StayLeft works it out from the log, starting the stay at the minute after the contact that changed band, which is where N1MM starts it. Which contests show it is decided at runtime rather than by a list: the rules come from the contest for the entry's own category, so a file that asks for no stay, or asks for one only of single-op entries while the station is entered multi-op, shows nothing at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- README.md | 13 +++++- docs/unfinished.md | 4 +- src/Nonemm.App/Windows/EntryWindow.axaml | 12 +++--- src/Nonemm.App/Windows/EntryWindow.axaml.cs | 27 +++++++++++- src/Nonemm.Contests/BandChangeRules.cs | 43 +++++++++++++++++++ .../BandChangeRulesTests.cs | 41 +++++++++++++++++- 6 files changed, 127 insertions(+), 13 deletions(-) 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); }