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);
}