Add the Info window, following N1MM's source

The window shows the operator, the call-frame spot, country, bearing and
sun lines, the WWV line and a message area. It draws both rate graphs
against the goals and shows the QSO timer (seven modes), the band timer,
the band-change count and the Run/Mult box.

Goals use N1MM's `dhh goal` file format and its Import Goals rules,
including statistics and CSV files. Logic that does not need the UI is in
Nonemm.Contests (Goals, QsoTimer, RateBars) and Nonemm.Session
(InfoLines), with tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 15:43:09 +00:00
co-authored by Claude Sonnet 5.5
parent 7a728af469
commit 012b0cdc8b
26 changed files with 2055 additions and 0 deletions
+20
View File
@@ -26,6 +26,26 @@ The Cabrillo output has not been put in front of a contest sponsor's robot.
## Half-built
**The info window: what N1MM has and this does not.** The window follows
`InfoWindow.cs` from the decompiled source: the caption, the call-frame spot,
the country, bearing and sun lines, the WWV line, the message area, both graphs
with goals, the QSO timer in all seven modes, the band timer, the band-change
count and the Run/Mult box. Goals use N1MM's `dhh goal` file format and its
Import Goals rules, including the statistics and CSV files. Left out:
- the multi-op category mismatch message and the CPU-load advisory for heavy
spot traffic;
- goals kept per contest in the Admin database, and the goal file that a
contest's Associated Files tab loads when the contest starts. Goals are one
list in `settings.json` that stays until it is replaced;
- the highlight on a spot from a preferred spotter;
- N1MM's time-since-last-QSO options 4 to 6, which its code limits by radio
number in a way that does not match their menu labels. This program follows
the labels;
- the hourly graph's slots end on the minute they start, where N1MM's end a
minute early (:19, :39, :59);
- a real check of the RBN link. It is N1MM's URL, and has not been opened.
**The digital window: what N1MM has and this does not.** The window is here —
receive pane in both of N1MM's scroll modes, the Letters/Figs and MouseOver
readouts, the pause bar, coloured callsigns with N1MM's validity routines, grab
+68
View File
@@ -0,0 +1,68 @@
using Nonemm.Spotting;
namespace Nonemm.App;
/// A line for the info window's message area.
/// `IsAlert` marks one the operator should see, such as a spot of their own call.
public sealed record Notice(DateTime AtUtc, string Text, bool IsAlert = false);
public sealed partial class AppSession
{
private const int NoticesKept = 50;
private readonly List<Notice> notices = [];
private readonly Lock noticesGuard = new();
/// The latest program messages, oldest first: what the keyers reported and
/// the spots of this station's own call.
public IReadOnlyList<Notice> Notices
{
get
{
lock (noticesGuard)
{
return [.. notices];
}
}
}
/// Raised on whatever thread posted the notice.
public event EventHandler? NoticesChanged;
public void PostNotice(string text, bool isAlert = false)
{
lock (noticesGuard)
{
notices.Add(new Notice(DateTime.UtcNow, text, isAlert));
if (notices.Count > NoticesKept)
{
notices.RemoveAt(0);
}
}
NoticesChanged?.Invoke(this, EventArgs.Empty);
}
public void ClearNotices()
{
lock (noticesGuard)
{
notices.Clear();
}
NoticesChanged?.Invoke(this, EventArgs.Empty);
}
/// Posts a notice for each spot of our own call in a batch from the cluster.
private void NoticeOwnSpots(IEnumerable<Spot> batch)
{
string mine = Settings.Station.Callsign.Trim();
if (mine.Length == 0)
{
return;
}
foreach (Spot spot in batch.Where(s => s.IsStation
&& string.Equals(s.Call.Text, mine, StringComparison.OrdinalIgnoreCase)))
{
PostNotice($"{spot.Spotter} spotted {spot.Call.Text} on {spot.Frequency.Kilohertz:0.0} {spot.Comment}".TrimEnd(), isAlert: true);
}
}
}
+2
View File
@@ -81,6 +81,7 @@ public sealed partial class AppSession : IDisposable
BandPlan = settings.ToBandPlan();
ApplySpotSettings();
spotFlush = new Timer(_ => FlushSpots(), null, SpotFlushInterval, SpotFlushInterval);
Reported += (_, text) => PostNotice(text);
}
/// How often the spots that have arrived go onto the bandmap. N1MM's own
@@ -1031,6 +1032,7 @@ public sealed partial class AppSession : IDisposable
batch = [.. arriving];
arriving.Clear();
}
NoticeOwnSpots(batch);
List<Spot> keeping = [];
foreach (Spot spot in batch)
{
+2
View File
@@ -59,6 +59,8 @@ public sealed record Settings
/// Which cluster spots reach the bandmap.
public StoredSpotFilter SpotFilter { get; init; } = new();
public StoredInfoWindow Info { get; init; } = new();
/// How long a CW or digital spot stays on the bandmap.
public int CwDigitalSpotTimeoutMinutes { get; init; } = 10;
@@ -0,0 +1,38 @@
using Nonemm.Contests;
namespace Nonemm.App.Configuration;
/// What the info window shows. Each flag is one item on its right-click menu.
public sealed record StoredInfoWindow
{
public bool CallFrameSpot { get; init; } = true;
public bool CountryInfo { get; init; } = true;
public bool SunTimes { get; init; } = true;
public bool Wwv { get; init; } = true;
public bool Messages { get; init; } = true;
public bool QsoTimers { get; init; } = true;
public bool ShowGoals { get; init; } = true;
/// Count the rates of every computer on the network rather than this one.
public bool NetworkedRate { get; init; }
/// Count only the contacts on the band the radio is on.
public bool BandRate { get; init; }
/// Show points on the left graph instead of contacts.
public bool ShowPoints { get; init; }
/// The length of one bar on the hourly graph: 20, 30 or 60.
public int HourlyMinutes { get; init; } = 30;
public QsoTimerOption TimerOption { get; init; } = QsoTimerOption.AllStations;
/// The goals in `Goals.ToText` form. Empty means none are set.
public string Goals { get; init; } = "";
}
+19
View File
@@ -0,0 +1,19 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.GoalsDialog"
Title="Change QSO Goals by Hour" Width="340" Height="440"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="6" Margin="0,12,0,0">
<Button Content="Export…" Click="OnExport" />
<Button Content="Cancel" Click="OnCancel" IsCancel="True" />
<Button Content="Ok" Click="OnOk" IsDefault="True" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Name="ProblemText" Foreground="OrangeRed" Margin="0,6,0,0"
TextWrapping="Wrap" />
<TextBlock DockPanel.Dock="Top" Margin="0,0,0,6" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="One goal per line: Hour (dhh), then Goal (Q's). dhh is the contest day, starting at 1, and the UTC clock hour: 112 is day 1, 12:00Z. A goal holds until the next line." />
<TextBox Name="GoalsBox" AcceptsReturn="True" FontFamily="monospace" />
</DockPanel>
</Window>
@@ -0,0 +1,73 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.Contests;
namespace Nonemm.App.Dialogs;
/// N1MM's goals editor, as the text the goals are kept in. It returns the
/// goals in `Goals.ToText` form, or null when cancelled.
public sealed partial class GoalsDialog : Window
{
private readonly string exportFolder;
public GoalsDialog(string goals, string exportFolder)
{
this.exportFolder = exportFolder;
InitializeComponent();
GoalsBox.Text = goals;
}
private void OnOk(object? sender, RoutedEventArgs e)
{
if (Checked() is { } text)
{
Close(text);
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
/// Writes the goals to a file N1MM's Import Goals reads, keeping the
/// dialog open.
private async void OnExport(object? sender, RoutedEventArgs e)
{
if (Checked() is not { } text)
{
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export goals",
SuggestedFileName = "goals.txt",
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(exportFolder),
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
try
{
await File.WriteAllTextAsync(path, text);
ProblemText.Text = $"{Path.GetFileName(path)} written.";
}
catch (IOException problem)
{
ProblemText.Text = $"could not write {path}: {problem.Message}";
}
}
private string? Checked()
{
try
{
ProblemText.Text = "";
return Goals.Parse(GoalsBox.Text ?? "").ToText();
}
catch (FormatException problem)
{
ProblemText.Text = problem.Message;
return null;
}
}
}
@@ -0,0 +1,26 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.ImportGoalsDialog"
Title="Import Goals" Width="320" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="6" Margin="0,12,0,0">
<Button Content="Cancel" Click="OnCancel" IsCancel="True" />
<Button Content="Choose File…" Click="OnChoose" IsDefault="True" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="Goals come from a goal file or from the Statistics window's text export. For a statistics file, pick the band whose column is used." />
<ComboBox Name="BandBox" SelectedIndex="0" HorizontalAlignment="Stretch">
<ComboBoxItem Content="All bands (total)" />
<ComboBoxItem Content="160 m" />
<ComboBoxItem Content="80 m" />
<ComboBoxItem Content="40 m" />
<ComboBoxItem Content="20 m" />
<ComboBoxItem Content="15 m" />
<ComboBoxItem Content="10 m" />
</ComboBox>
</StackPanel>
</DockPanel>
</Window>
@@ -0,0 +1,23 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace Nonemm.App.Dialogs;
/// The first half of N1MM's Import Goals: which band's column of a statistics
/// file to read. It returns the column counted back from the total, as
/// `Goals.Parse` takes it, or null when cancelled.
public sealed partial class ImportGoalsDialog : Window
{
/// The columns of a statistics file run from 160 m to 10 m and then the
/// total, so 160 m is six from the end.
private static readonly int[] FromEnd = [0, 6, 5, 4, 3, 2, 1];
public ImportGoalsDialog()
{
InitializeComponent();
}
private void OnChoose(object? sender, RoutedEventArgs e) => Close(FromEnd[Math.Max(0, BandBox.SelectedIndex)]);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -480,6 +480,11 @@ public sealed partial class EntryWindow
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
Show(() => new GraylineWindow(session));
/// N1MM's info window: the operator, the station in the call box, the rate
/// graphs and the contest timers.
private void OnShowInfo(object? sender, RoutedEventArgs e) =>
Show(() => new InfoWindow(session));
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
private void OnShowVoiceKeyer(object? sender, RoutedEventArgs e) =>
@@ -115,6 +115,7 @@ public sealed partial class EntryWindow
(nameof(StatisticsWindow), () => _ = Show(() => new StatisticsWindow(session), activate: false)),
(nameof(NetworkStatusWindow), () => _ = Show(() => new NetworkStatusWindow(session), activate: false)),
(nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)),
(nameof(InfoWindow), () => _ = Show(() => new InfoWindow(session), activate: false)),
(nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)),
(nameof(DigitalWindow), () => _ = Show(() => new DigitalWindow(session, this, radioNumber), activate: false)),
(nameof(VoiceKeyerWindow), () => _ = Show(() => new VoiceKeyerWindow(session), activate: false)),
+1
View File
@@ -127,6 +127,7 @@
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
<MenuItem Header="Info" Click="OnShowInfo" />
<MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Statistics" Click="OnShowStatistics" />
<MenuItem Header="Telnet" Click="OnShowTelnet" />
@@ -537,6 +537,11 @@ public sealed partial class EntryWindow : Window
e.Handled = true;
OnShowLog(this, new RoutedEventArgs());
break;
case Key.M when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
session.IsRun2 = !session.IsRun2;
break;
case Key.M when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnToggleEsm(this, new RoutedEventArgs());
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Media;
namespace Nonemm.App.Windows;
/// The three colours N1MM's info window uses for a figure against its target.
public static class InfoColours
{
public static readonly IBrush Good = new SolidColorBrush(Color.FromRgb(0xB8, 0xE6, 0xB0));
public static readonly IBrush Fair = new SolidColorBrush(Color.FromRgb(0xF5, 0xE0, 0x7A));
public static readonly IBrush Poor = new SolidColorBrush(Color.FromRgb(0xE8, 0x9A, 0x8F));
}
+158
View File
@@ -0,0 +1,158 @@
using Nonemm.Contests;
using Nonemm.Core;
namespace Nonemm.App.Windows;
public sealed partial class InfoWindow
{
/// The graphs only change with the clock every so often, so the timer
/// redraws them on one tick in this many.
private const int GraphEveryTicks = 15;
/// N1MM leaves this much room above the tallest bar.
private const double RateHeadroom = 1.35;
private const int EmptyScale = 100;
private string goalsShown = "";
private Goals goals = Goals.Default;
private bool IsNetworked => session.Link is not null || session.Network is not null;
/// The contacts the graphs count: the claimed ones, narrowed to this
/// computer on a network unless the menu says all, and to the radio's band
/// if it says so.
private IReadOnlyList<Qso> GraphContacts()
{
if (Logging is null)
{
return [];
}
IEnumerable<Qso> found = Logging.Log.Qsos.Where(q => q.IsClaimed && Logging.Contest.IsContact(q));
if (!Info.NetworkedRate && IsNetworked)
{
found = found.Where(q => q.StationName == session.ComputerName);
}
if (Info.BandRate && session.Position?.Band is { } band)
{
found = found.Where(q => q.Band == band);
}
return [.. found];
}
private Goals CurrentGoals()
{
if (Info.Goals != goalsShown)
{
goalsShown = Info.Goals;
try
{
goals = Goals.Parse(goalsShown);
}
catch (FormatException)
{
goals = Goals.Default;
}
}
return goals;
}
private void ShowGraphs()
{
if (Logging is null)
{
RateGraph.Bars = [];
GoalGraph.Bars = [];
RateLabel.Text = "";
GoalLabel.Text = "";
NextGoalText.Text = "";
RateGraph.InvalidateVisual();
GoalGraph.InvalidateVisual();
return;
}
DateTime now = DateTime.UtcNow;
IReadOnlyList<Qso> contacts = GraphContacts();
ShowRates(contacts, now);
ShowHourly(contacts, now);
}
private void ShowRates(IReadOnlyList<Qso> contacts, DateTime now)
{
TimeSpan minimum = Logging!.Contest.MinimumOffTime;
TimeSpan hour = TimeSpan.FromHours(1);
int goal = Info.ShowGoals ? CurrentGoals().At(now, Logging.Instance.StartDate) : 0;
int[] values = Info.ShowPoints
?
[
RateBars.PointsOfLast(contacts, 10, now, minimum),
RateBars.PointsOfLast(contacts, 100, now, minimum),
RateBars.PointsWithin(contacts, now, hour),
RateBars.PointsThisClockHour(contacts, now),
]
:
[
Rates.PerHour(contacts, 10, now, minimum),
Rates.PerHour(contacts, 100, now, minimum),
RateBars.Within(contacts, now, hour),
RateBars.ThisClockHour(contacts, now),
];
bool isColoured = Info.ShowGoals && !Info.ShowPoints;
string[] names = ["10", "100", "60m", "hr"];
RateGraph.Bars = [.. values.Select((v, at) => new RateBar(names[at], v, isColoured ? LevelOf(v, goal) : RateLevel.Plain))];
int tallest = Math.Max(values.Max(), isColoured ? goal : 0);
RateGraph.Maximum = tallest > 0 ? tallest * RateHeadroom : EmptyScale;
RateGraph.Shaded = isColoured ? goal : null;
RateLabel.Text = $"{(Info.ShowPoints ? "Points" : "Q's")} - 10, 100, 60m, {now.Minute}m";
RateGraph.InvalidateVisual();
}
/// A bar at or over the goal is green, at half the goal or under is red,
/// and the rest are yellow.
private static RateLevel LevelOf(int rate, int goal) =>
rate >= goal ? RateLevel.Good : rate <= 0.5 * goal ? RateLevel.Poor : RateLevel.Fair;
private void ShowHourly(IReadOnlyList<Qso> contacts, DateTime now)
{
int step = Info.HourlyMinutes;
IReadOnlyList<int> rates = RateBars.Hourly(contacts, now, step);
DateTime start = Logging!.Instance.StartDate;
Goals wanted = CurrentGoals();
IReadOnlyList<int?> aims =
[
.. rates.Select((_, at) => Info.ShowGoals
? (int?)wanted.At(now.AddMinutes(-(rates.Count - 1 - at) * step), start)
: null),
];
DateTime current = now.AddMinutes(-(now.Minute % step));
GoalGraph.Bars =
[
.. rates.Select((rate, at) => new RateBar(
current.AddMinutes(-(rates.Count - 1 - at) * step).ToString("HH:mm"),
rate,
RateLevel.Plain,
aims[at])),
];
int tallest = Math.Max(rates.Max(), aims.Max() ?? 0);
// N1MM's scale: about a tenth of the tallest figure, plus one, times ten
GoalGraph.Maximum = tallest > 0 ? (((int)Math.Round(tallest * 0.11)) + 1) * 10 : EmptyScale;
GoalGraph.ShowGoalLine = Info.ShowGoals;
NextGoalText.Text = Info.ShowGoals
? $"Next Goal {wanted.At(now.AddMinutes(step), start)}"
: "";
GoalLabel.Text = GoalTitle();
GoalGraph.InvalidateVisual();
}
private string GoalTitle()
{
if (!IsNetworked)
{
return Info.BandRate ? "Active Band - Q's/Hr" : "Q's/Hr";
}
if (Info.NetworkedRate)
{
return Info.BandRate ? "My Band All Stns - Q's/Hr" : "All Stations - Q's/Hr";
}
return Info.BandRate ? "Active Band - Q's/Hr" : $"{session.ComputerName.ToUpperInvariant()} - Q's/Hr";
}
}
+164
View File
@@ -0,0 +1,164 @@
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.Contests;
namespace Nonemm.App.Windows;
public sealed partial class InfoWindow
{
private const string HelpUrl = "https://n1mmwp.hamdocs.com/manual-windows/info-window/";
private void OnMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
{
SpotItem.IsChecked = Info.CallFrameSpot;
CountryItem.IsChecked = Info.CountryInfo;
SunItem.IsChecked = Info.SunTimes;
WwvItem.IsChecked = Info.Wwv;
MessagesItem.IsChecked = Info.Messages;
GoalsItem.IsChecked = Info.ShowGoals;
TimersItem.IsChecked = Info.QsoTimers;
NetworkedItem.IsChecked = Info.NetworkedRate;
NetworkedItem.IsEnabled = IsNetworked;
BandItem.IsChecked = Info.BandRate;
ContactsItem.IsChecked = !Info.ShowPoints;
PointsItem.IsChecked = Info.ShowPoints;
Minutes20Item.IsChecked = Info.HourlyMinutes == 20;
Minutes30Item.IsChecked = Info.HourlyMinutes == 30;
Minutes60Item.IsChecked = Info.HourlyMinutes == 60;
}
private void Change(Func<StoredInfoWindow, StoredInfoWindow> change)
{
session.Save(session.Settings with { Info = change(Info) });
Refresh();
}
private void OnToggleSpot(object? sender, RoutedEventArgs e) =>
Change(i => i with { CallFrameSpot = !i.CallFrameSpot });
private void OnToggleCountry(object? sender, RoutedEventArgs e) =>
Change(i => i with { CountryInfo = !i.CountryInfo });
private void OnToggleSun(object? sender, RoutedEventArgs e) =>
Change(i => i with { SunTimes = !i.SunTimes });
private void OnToggleWwv(object? sender, RoutedEventArgs e) =>
Change(i => i with { Wwv = !i.Wwv });
private void OnToggleMessages(object? sender, RoutedEventArgs e) =>
Change(i => i with { Messages = !i.Messages });
private void OnToggleGoals(object? sender, RoutedEventArgs e) =>
Change(i => i with { ShowGoals = !i.ShowGoals });
private void OnToggleTimers(object? sender, RoutedEventArgs e) =>
Change(i => i with { QsoTimers = !i.QsoTimers });
private void OnToggleNetworked(object? sender, RoutedEventArgs e) =>
Change(i => i with { NetworkedRate = !i.NetworkedRate });
private void OnToggleBand(object? sender, RoutedEventArgs e) =>
Change(i => i with { BandRate = !i.BandRate });
private void OnShowContacts(object? sender, RoutedEventArgs e) => Change(i => i with { ShowPoints = false });
private void OnShowPoints(object? sender, RoutedEventArgs e) => Change(i => i with { ShowPoints = true });
private void OnMinutes20(object? sender, RoutedEventArgs e) => Change(i => i with { HourlyMinutes = 20 });
private void OnMinutes30(object? sender, RoutedEventArgs e) => Change(i => i with { HourlyMinutes = 30 });
private void OnMinutes60(object? sender, RoutedEventArgs e) => Change(i => i with { HourlyMinutes = 60 });
private async void OnCopyMessages(object? sender, RoutedEventArgs e)
{
if (Clipboard is { } clipboard)
{
await clipboard.SetTextAsync(string.Join(
'\n',
session.Notices.Select(n => $"{n.AtUtc:HH:mm:ss} {n.Text}")));
}
}
private void OnClearMessages(object? sender, RoutedEventArgs e) => session.ClearNotices();
/// Opens the Reverse Beacon Network's page for this station's call, which
/// lists who has heard it lately. It is useful on CW and RTTY and says
/// nothing about phone.
private void OnShowRbn(object? sender, RoutedEventArgs e)
{
string call = session.Settings.Station.Callsign.Trim();
if (call.Length > 0)
{
Open($"http://www.reversebeacon.net/dxsd1/dxsd1.php?f=0&c={Uri.EscapeDataString(call)}&t=dx");
}
}
private void OnHelp(object? sender, RoutedEventArgs e) => Open(HelpUrl);
private void Open(string url)
{
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception error) when (error is System.ComponentModel.Win32Exception or IOException)
{
session.PostNotice($"could not open a browser for {url}: {error.Message}");
}
}
private async void OnEditGoals(object? sender, RoutedEventArgs e)
{
string current = Info.Goals.Length > 0 ? Info.Goals : Goals.Default.ToText();
if (await new GoalsDialog(current, session.Paths.GoalFiles).ShowDialog<string?>(this) is { } edited)
{
Change(i => i with { Goals = edited });
}
}
/// N1MM asks which band's column to take from a statistics file, then
/// which file.
private async void OnImportGoals(object? sender, RoutedEventArgs e)
{
if (await new ImportGoalsDialog().ShowDialog<int?>(this) is not { } fromEnd)
{
return;
}
IReadOnlyList<IStorageFile> picked = await StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions
{
Title = "Select Filename",
AllowMultiple = false,
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(session.Paths.GoalFiles),
FileTypeFilter =
[
new FilePickerFileType("Goal TXT Files") { Patterns = ["*.txt"] },
new FilePickerFileType("Goal CSV files") { Patterns = ["*.csv"] },
],
});
if (picked.Count == 0)
{
return;
}
string path = picked[0].Path.LocalPath;
try
{
Goals imported = Goals.Parse(
await File.ReadAllTextAsync(path),
isCsv: path.EndsWith(".csv", StringComparison.OrdinalIgnoreCase),
fromEnd);
Change(i => i with { Goals = imported.ToText() });
session.PostNotice($"goals imported from {picked[0].Name}");
}
catch (Exception problem) when (problem is FormatException or IOException)
{
session.PostNotice($"could not import goals from {picked[0].Name}: {problem.Message}");
}
}
}
+152
View File
@@ -0,0 +1,152 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Nonemm.Contests;
using Nonemm.Core;
namespace Nonemm.App.Windows;
public sealed partial class InfoWindow
{
/// The contest names whose band timer N1MM calls a band/multiplier timer.
private static readonly string[] BandMultTimers = ["ARIDX", "LZBALCHIK"];
private void ShowTimers()
{
if (Logging is null)
{
QsoTimerFrame.IsVisible = false;
BandTimerFrame.IsVisible = false;
BandChangeFrame.IsVisible = false;
RunBox.IsVisible = false;
return;
}
DateTime now = DateTime.UtcNow;
IReadOnlyList<Qso> contacts = [.. Logging.Log.Qsos.Where(Logging.Contest.IsContact)];
ShowQsoTimer(contacts, now);
ShowBandRules(contacts, now);
ShowMultRun();
}
private void ShowQsoTimer(IReadOnlyList<Qso> contacts, DateTime now)
{
QsoTimerFrame.IsVisible = Info.QsoTimers;
if (!Info.QsoTimers)
{
return;
}
TimeSpan minimum = Logging!.Contest.MinimumOffTime;
QsoTimerReading reading = QsoTimer.Read(
contacts,
Info.TimerOption,
minimum,
Logging.Instance.StartDate,
now,
session.ComputerName);
QsoTimerCaption.Text = QsoTimer.Caption(Info.TimerOption, (int)minimum.TotalMinutes, session.Positions.Count > 1);
QsoTimerText.Text = reading.Text;
Paint(QsoTimerBox, QsoTimerText, reading.State switch
{
TimerState.Met => InfoColours.Good,
TimerState.Warning => InfoColours.Fair,
TimerState.Over => InfoColours.Poor,
_ => null,
});
}
/// The band timer of a contest that makes the station stay on a band, and
/// the count of band changes of a contest that limits them.
private void ShowBandRules(IReadOnlyList<Qso> contacts, DateTime now)
{
BandChangeRules rules = Logging!.Contest.BandChangesFor(Logging.Log.Entry);
BandTimerFrame.IsVisible = rules.HasStayTimer;
if (rules.HasStayTimer)
{
TimeSpan left = rules.StayLeft(contacts, now);
BandTimerText.Text = $"{(int)left.TotalMinutes:00}:{left.Seconds:00}";
Paint(
BandTimerBox,
BandTimerText,
left == TimeSpan.Zero ? InfoColours.Good : left < TimeSpan.FromMinutes(1) ? InfoColours.Fair : null);
BandTimerCaption.Text = $"{(int)rules.MinimumStay.TotalMinutes} Min {BandTimerName()} Timer";
}
BandChangeFrame.IsVisible = rules.IsCounted;
if (rules.IsCounted)
{
int changes = rules.CountedIn(contacts, now);
BandChangeText.Text = changes.ToString();
Paint(
BandChangeBox,
BandChangeText,
changes < rules.Max - 1 ? InfoColours.Good : changes == rules.Max - 1 ? InfoColours.Fair : InfoColours.Poor);
}
}
private string BandTimerName()
{
string contest = Logging!.Instance.ContestName;
bool isBandMult = BandMultTimers.Contains(contest)
|| (contest == "IARU" && EntryCategories.IsMultiOne(Logging.Log.Entry));
return isBandMult ? "B/M" : "Band";
}
/// `Run1` or `Run2` for a multi-two entry, `RUN` or `MULT` for a
/// multi-one entry, and nothing for any other category.
private void ShowMultRun()
{
ContestEntry entry = Logging!.Log.Entry;
bool isOne = EntryCategories.IsMultiOne(entry);
RunBox.IsVisible = isOne || EntryCategories.IsMultiTwo(entry);
if (RunBox.IsVisible)
{
RunText.Text = isOne
? session.IsRun2 ? "MULT" : "RUN"
: session.IsRun2 ? "Run2" : "Run1";
}
}
private static void Paint(Border box, TextBlock text, IBrush? background)
{
box.Background = background;
if (background is null)
{
text.ClearValue(TextBlock.ForegroundProperty);
}
else
{
text.Foreground = Brushes.Black;
}
}
private void OnTimerMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
{
CountDownItem.IsChecked = Info.TimerOption == QsoTimerOption.CountDown;
CountUpItem.IsChecked = Info.TimerOption == QsoTimerOption.CountUp;
CumulativeItem.IsChecked = Info.TimerOption == QsoTimerOption.Cumulative;
AllStationsItem.IsChecked = Info.TimerOption == QsoTimerOption.AllStations;
ThisStationItem.IsChecked = Info.TimerOption == QsoTimerOption.ThisStation;
LeftRadioItem.IsChecked = Info.TimerOption == QsoTimerOption.LeftRadio;
RightRadioItem.IsChecked = Info.TimerOption == QsoTimerOption.RightRadio;
}
private void UseTimer(QsoTimerOption option)
{
session.Save(session.Settings with { Info = Info with { TimerOption = option } });
ShowTimers();
}
private void OnTimerCountDown(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.CountDown);
private void OnTimerCountUp(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.CountUp);
private void OnTimerCumulative(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.Cumulative);
private void OnTimerAllStations(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.AllStations);
private void OnTimerThisStation(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.ThisStation);
private void OnTimerLeftRadio(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.LeftRadio);
private void OnTimerRightRadio(object? sender, RoutedEventArgs e) => UseTimer(QsoTimerOption.RightRadio);
}
+114
View File
@@ -0,0 +1,114 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.InfoWindow"
Title="Info" Width="560" Height="470">
<local:RefreshableWindow.ContextMenu>
<ContextMenu Opening="OnMenuOpening">
<MenuItem Name="SpotItem" Header="Callframe Spot" ToggleType="CheckBox" Click="OnToggleSpot" />
<MenuItem Name="CountryItem" Header="Country Info" ToggleType="CheckBox" Click="OnToggleCountry" />
<MenuItem Name="SunItem" Header="Sunrise/Sunset" ToggleType="CheckBox" Click="OnToggleSun" />
<MenuItem Name="WwvItem" Header="WWV Messages" ToggleType="CheckBox" Click="OnToggleWwv" />
<MenuItem Name="MessagesItem" Header="Message Window" ToggleType="CheckBox" Click="OnToggleMessages" />
<MenuItem Name="GoalsItem" Header="Show Goals" ToggleType="CheckBox" Click="OnToggleGoals" />
<MenuItem Name="TimersItem" Header="Show QSO Timers" ToggleType="CheckBox" Click="OnToggleTimers" />
<Separator />
<MenuItem Name="NetworkedItem" Header="Rates &amp; Goals - All Computers" ToggleType="CheckBox"
Click="OnToggleNetworked" />
<MenuItem Name="BandItem" Header="Rates &amp; Goals - Only Current Band" ToggleType="CheckBox"
Click="OnToggleBand" />
<MenuItem Name="ContactsItem" Header="Contacts" ToggleType="Radio" Click="OnShowContacts" />
<MenuItem Name="PointsItem" Header="Points" ToggleType="Radio" Click="OnShowPoints" />
<Separator />
<MenuItem Name="Minutes20Item" Header="Goals Chart - 20min Moving Average" ToggleType="Radio"
Click="OnMinutes20" />
<MenuItem Name="Minutes30Item" Header="Goals Chart - 30min Moving Average" ToggleType="Radio"
Click="OnMinutes30" />
<MenuItem Name="Minutes60Item" Header="Goals Chart - 60min Moving Average" ToggleType="Radio"
Click="OnMinutes60" />
<Separator />
<MenuItem Header="Edit, Export Goals…" Click="OnEditGoals" />
<MenuItem Header="Import Goals…" Click="OnImportGoals" />
<Separator />
<MenuItem Header="Copy Message Text to Clipboard" Click="OnCopyMessages" />
<MenuItem Header="Clear Message Window" Click="OnClearMessages" />
<MenuItem Header="Show RBN Spots of this Station" Click="OnShowRbn" />
<MenuItem Header="Help" Click="OnHelp" />
</ContextMenu>
</local:RefreshableWindow.ContextMenu>
<DockPanel Margin="4">
<ScrollViewer DockPanel.Dock="Bottom" Name="MessagesScroll" Height="90" Margin="0,4,0,0">
<SelectableTextBlock Name="MessagesText" FontSize="11" TextWrapping="Wrap" />
</ScrollViewer>
<TextBlock DockPanel.Dock="Bottom" Name="StatusText" FontSize="11" Opacity="0.8" Margin="0,2,0,0" />
<StackPanel DockPanel.Dock="Top" Spacing="1" Margin="0,0,0,4">
<TextBlock Name="SpotText" FontFamily="monospace" FontSize="12" TextTrimming="CharacterEllipsis" />
<TextBlock Name="CountryText" FontFamily="monospace" FontSize="12" TextTrimming="CharacterEllipsis" />
<TextBlock Name="SunText" FontFamily="monospace" FontSize="12" TextTrimming="CharacterEllipsis" />
<TextBlock Name="WwvText" FontFamily="monospace" FontSize="12" TextTrimming="CharacterEllipsis" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" ColumnDefinitions="*,*" RowDefinitions="Auto,*,Auto" Margin="0,0,6,0">
<TextBlock Grid.Row="0" Grid.Column="0" Name="RateLabel" FontSize="11" HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="1" Name="GoalLabel" FontSize="11" HorizontalAlignment="Center" />
<local:RateGraphView Grid.Row="1" Grid.Column="0" Name="RateGraph" Margin="0,2,3,0"
ToolTip.Tip="Rates for the last 10 and 100 QSOs, the last 60 minutes and since the top of the hour. The shaded area is the goal." />
<local:RateGraphView Grid.Row="1" Grid.Column="1" Name="GoalGraph" Margin="3,2,0,0" />
<TextBlock Grid.Row="2" Grid.Column="1" Name="NextGoalText" FontSize="11" HorizontalAlignment="Center" />
</Grid>
<StackPanel Grid.Column="1" Width="150" Spacing="4">
<TextBlock Name="OperatorText" FontSize="18" FontWeight="Bold" HorizontalAlignment="Right" />
<Border Name="QsoTimerFrame" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="4,2">
<Border.ContextMenu>
<ContextMenu Opening="OnTimerMenuOpening">
<MenuItem Header="Display Off Time (full minutes with no QSO's)">
<MenuItem Name="CountDownItem" Header="Current Interval Count Down Timer" ToggleType="Radio"
Click="OnTimerCountDown" />
<MenuItem Name="CountUpItem" Header="Current Interval Count Up Timer" ToggleType="Radio"
Click="OnTimerCountUp" />
<MenuItem Name="CumulativeItem" Header="Cumulative Contest Off Time" ToggleType="Radio"
Click="OnTimerCumulative" />
</MenuItem>
<MenuItem Header="Time Since Last QSO (not for off time rule compliance)">
<MenuItem Name="AllStationsItem" Header="All Computers &amp; Radios" ToggleType="Radio"
Click="OnTimerAllStations" />
<MenuItem Name="ThisStationItem" Header="Both Radios (SO2R), VFO A/B (SO1V/2V)" ToggleType="Radio"
Click="OnTimerThisStation" />
<MenuItem Name="LeftRadioItem" Header="Left Radio (SO2R), VFOA (SO2V)" ToggleType="Radio"
Click="OnTimerLeftRadio" />
<MenuItem Name="RightRadioItem" Header="Right Radio (SO2R), VFO B (SO2V)" ToggleType="Radio"
Click="OnTimerRightRadio" />
</MenuItem>
</ContextMenu>
</Border.ContextMenu>
<StackPanel>
<TextBlock Name="QsoTimerCaption" FontSize="10" />
<Border Name="QsoTimerBox" Padding="4,1" CornerRadius="2">
<TextBlock Name="QsoTimerText" FontFamily="monospace" FontSize="18" HorizontalAlignment="Center" />
</Border>
</StackPanel>
</Border>
<Border Name="BandTimerFrame" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="4,2">
<StackPanel>
<TextBlock Name="BandTimerCaption" FontSize="10" />
<Border Name="BandTimerBox" Padding="4,1" CornerRadius="2">
<TextBlock Name="BandTimerText" FontFamily="monospace" FontSize="18" HorizontalAlignment="Center" />
</Border>
</StackPanel>
</Border>
<Border Name="BandChangeFrame" BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="4,2">
<StackPanel>
<TextBlock Text="Band Chgs" FontSize="10" />
<Border Name="BandChangeBox" Padding="4,1" CornerRadius="2">
<TextBlock Name="BandChangeText" FontFamily="monospace" FontSize="18" HorizontalAlignment="Center" />
</Border>
</StackPanel>
</Border>
<Border Name="RunBox" Padding="4,1" CornerRadius="2" BorderBrush="Gray" BorderThickness="1">
<TextBlock Name="RunText" FontFamily="monospace" FontSize="16" HorizontalAlignment="Center"
ToolTip.Tip="Ctrl+Alt+M toggles Run and Mult" />
</Border>
</StackPanel>
</Grid>
</DockPanel>
</local:RefreshableWindow>
+188
View File
@@ -0,0 +1,188 @@
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
/// N1MM's info window: who is operating, what is known about the station in the
/// call box, the rate graphs against the goals, and the timers contests with a
/// rest rule or a band-change rule ask the operator to watch.
public sealed partial class InfoWindow : RefreshableWindow
{
/// How far from the radio's frequency a spot may be and still be the one
/// the call-frame line shows.
private static readonly Frequency SpotReach = Frequency.FromHertz(500);
private readonly AppSession session;
private readonly DispatcherTimer clock;
private readonly Throttled later;
private readonly EventHandler onNotices;
private readonly EventHandler onSolar;
private int ticks;
public InfoWindow(AppSession session)
{
this.session = session;
InitializeComponent();
later = new Throttled(TimeSpan.FromSeconds(2), Refresh, holdWhileTyping: true);
clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
clock.Tick += (_, _) =>
{
ShowTimers();
if (++ticks % GraphEveryTicks == 0)
{
ShowGraphs();
}
};
clock.Start();
onNotices = (_, _) => Dispatcher.UIThread.Post(ShowMessages);
onSolar = (_, _) => Dispatcher.UIThread.Post(ShowWwv);
session.NoticesChanged += onNotices;
session.SolarChanged += onSolar;
Closed += (_, _) =>
{
clock.Stop();
later.Cancel();
session.NoticesChanged -= onNotices;
session.SolarChanged -= onSolar;
};
Refresh();
}
private ContestSession? Logging => session.Logging;
private Configuration.StoredInfoWindow Info => session.Settings.Info;
public override void RefreshFromSession() => later.Request();
public override void RefreshForEntry() => ShowLines();
public override void Refresh()
{
ShowHeader();
ShowLines();
ShowWwv();
ShowMessages();
ShowGraphs();
ShowTimers();
}
/// N1MM's caption: `Info - DL1ABC - Exch: 599 001`, where the number is the
/// next one to be sent in a contest that sends one.
private void ShowHeader()
{
string station = session.Settings.Station.Callsign;
if (Logging is null || session.Position is not { } position)
{
Title = $"Info - {station}";
OperatorText.Text = station;
StatusText.Text = "";
return;
}
string report = position.Entry.Exchange.Any(f => f.Slot == Contests.ExchangeSlot.SentReport)
? position.DefaultReport()
: "";
string sent = Logging.Instance.SentExchange;
string exchange = sent.Contains("001") || sent.Contains('#')
? position.SentNumber.ToString().PadLeft(3, '0')
: sent;
Title = $"Info - {station} - Exch: {report} {exchange}";
OperatorText.Text = Logging.Operator.Length > 0
? Logging.Operator
: session.Settings.CurrentOperator.Length > 0 ? session.Settings.CurrentOperator : station;
StatusText.Text = Info.ShowGoals ? $"Contest Start Time: {Logging.Instance.StartDate:yyyy-MM-dd HH:mm:ss}Z" : "";
}
/// The spot, the country with its bearing, and the sun there. They follow
/// the call in the entry window, and the spot under the radio's frequency
/// when no call is being typed.
private void ShowLines()
{
SpotText.IsVisible = Info.CallFrameSpot;
CountryText.IsVisible = Info.CountryInfo;
SunText.IsVisible = Info.SunTimes;
SpotText.Text = "";
CountryText.Text = "";
SunText.Text = "";
if (session.Position is not { } position)
{
return;
}
DateTime now = DateTime.UtcNow;
string call = position.Entry.Call.Trim();
Spot? spot = SpotFor(position, call);
if (spot is not null)
{
SpotText.Text = InfoLines.Spot(spot, now);
}
CountryLookup? country = call.Length >= 2
? position.Country()
: spot is null ? null : session.Countries?.Find(spot.Call);
if (country is null)
{
return;
}
StationPath? path = call.Length >= 2
? StationPath.For(position, now)
: StationPath.ToCall(position, spot!.Call, now);
string where = InfoLines.Country(country, Logging?.Contest.UsesItuZones ?? false);
CountryText.Text = path is null ? where : $"{where}, {InfoLines.Bearing(path, position.Me.Continent == "NA")}";
SunLine sun = InfoLines.Sun(country, now);
SunText.Text = sun.Text;
if (sun.IsSoon)
{
SunText.Foreground = Brushes.OrangeRed;
}
else
{
SunText.ClearValue(TextBlock.ForegroundProperty);
}
}
private Spot? SpotFor(OperatingPosition position, string call)
{
if (call.Length < 2)
{
return session.Bandmap.Near(position.Frequency, SpotReach) is { IsStation: true } near ? near : null;
}
Band? band = position.Band;
return session.Bandmap.All()
.Where(s => s.IsStation && string.Equals(s.Call.Text, call, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(s => s.Band == band)
.ThenByDescending(s => s.AtUtc)
.FirstOrDefault();
}
private void ShowWwv()
{
WwvText.IsVisible = Info.Wwv;
WwvText.Text = session.Solar?.Original ?? "";
}
private void ShowMessages()
{
MessagesScroll.IsVisible = Info.Messages;
if (!Info.Messages)
{
return;
}
MessagesText.Inlines?.Clear();
foreach (Notice notice in session.Notices)
{
Run line = new($"{notice.AtUtc:HH:mm:ss} {notice.Text}\n");
if (notice.IsAlert)
{
line.FontWeight = FontWeight.Bold;
line.Foreground = Brushes.OrangeRed;
}
MessagesText.Inlines?.Add(line);
}
MessagesScroll.ScrollToEnd();
}
}
+102
View File
@@ -0,0 +1,102 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Nonemm.App.Theming;
namespace Nonemm.App.Windows;
public enum RateLevel
{
Plain,
Good,
Fair,
Poor,
}
/// One bar of a rate graph: the label under it, its figure, the colour it is
/// drawn in and the goal for the same stretch, if there is one.
public sealed record RateBar(string Label, int Value, RateLevel Level = RateLevel.Plain, int? Goal = null);
/// The two small bar graphs in the info window.
public sealed class RateGraphView : Control
{
private const double LabelHeight = 14;
private const double Gap = 6;
private readonly Typeface typeface = new(Fonts.Mono);
public IReadOnlyList<RateBar> Bars { get; set; } = [];
/// The top of the scale, or zero to scale to the tallest bar.
public double Maximum { get; set; }
/// A goal drawn as a shaded band across the whole graph.
public int? Shaded { get; set; }
/// Join the bars' own goals with a line.
public bool ShowGoalLine { get; set; }
public override void Render(DrawingContext context)
{
IBrush ink = this.FindResource("SystemControlForegroundBaseHighBrush") as IBrush ?? Brushes.Gray;
IBrush accent = this.FindResource("SystemControlHighlightAccentBrush") as IBrush ?? Brushes.SteelBlue;
context.DrawRectangle(null, new Pen(ink, 1), new Rect(0.5, 0.5, Bounds.Width - 1, Bounds.Height - 1));
if (Bars.Count == 0)
{
return;
}
double top = Maximum > 0 ? Maximum : Math.Max(1, Bars.Max(b => b.Value));
Rect plot = new(Gap, Gap, Math.Max(1, Bounds.Width - (2 * Gap)), Math.Max(1, Bounds.Height - LabelHeight - (2 * Gap)));
double slot = plot.Width / Bars.Count;
if (Shaded is { } goal)
{
double height = Math.Min(plot.Height, plot.Height * goal / top);
context.FillRectangle(new SolidColorBrush(Colors.Gray, 0.3), new Rect(plot.Left, plot.Bottom - height, plot.Width, height));
FormattedText label = Text($"Goal {goal}", 10, ink);
context.DrawText(label, new Point(plot.Right - label.Width, plot.Top));
}
List<Point> line = [];
for (int at = 0; at < Bars.Count; at++)
{
RateBar one = Bars[at];
double height = Math.Min(plot.Height, plot.Height * one.Value / top);
Rect column = new(plot.Left + (at * slot) + 3, plot.Bottom - height, Math.Max(1, slot - 6), height);
context.FillRectangle(Fill(one.Level, accent), column);
FormattedText value = Text(one.Value.ToString(CultureInfo.InvariantCulture), 11, ink);
context.DrawText(value, new Point(column.Center.X - (value.Width / 2), Math.Max(plot.Top, column.Top - value.Height)));
FormattedText name = Text(one.Label, 10, ink);
context.DrawText(name, new Point(column.Center.X - (name.Width / 2), plot.Bottom + 1));
if (one.Goal is { } wanted)
{
line.Add(new Point(column.Center.X, plot.Bottom - Math.Min(plot.Height, plot.Height * wanted / top)));
}
}
if (ShowGoalLine)
{
Pen pen = new(Brushes.OrangeRed, 2);
for (int at = 1; at < line.Count; at++)
{
context.DrawLine(pen, line[at - 1], line[at]);
}
foreach (Point point in line)
{
context.DrawEllipse(Brushes.OrangeRed, null, point, 2.5, 2.5);
}
}
}
private static IBrush Fill(RateLevel level, IBrush accent) => level switch
{
RateLevel.Good => InfoColours.Good,
RateLevel.Fair => InfoColours.Fair,
RateLevel.Poor => InfoColours.Poor,
_ => accent,
};
private FormattedText Text(string text, double size, IBrush ink) =>
new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, size, ink);
}
+178
View File
@@ -0,0 +1,178 @@
using System.Globalization;
using System.Text;
namespace Nonemm.Contests;
/// The contacts an hour the operator is aiming for. N1MM's info window draws
/// them over its rate graphs.
///
/// A goal is keyed by `dhh`: the contest day (the first day is 1) and the UTC
/// clock hour, so 112 is day 1, 12:00Z. A goal stays in force until the next
/// key, so `100 60` followed by `108 90` aims for 60 an hour from day 1, 00:00Z
/// up to 08:00Z and 90 from then on.
public sealed class Goals
{
/// After this many minutes the contest is over and the goal is nothing.
private const int ContestMinutes = 5760;
/// What N1MM aims for when no goals have been set: 50 an hour from the
/// start.
public static readonly Goals Default = new([(100, 50)], isDefault: true);
private readonly IReadOnlyList<(int Hour, int Goal)> entries;
private Goals(IReadOnlyList<(int Hour, int Goal)> entries, bool isDefault)
{
this.entries = entries;
IsDefault = isDefault;
}
public bool IsDefault { get; }
/// Reads a goal file, as N1MM's Import Goals does.
///
/// | File | Line |
/// |---|---|
/// | goal file | `dhh goal`, split by blanks or tabs |
/// | `.csv` (`isCsv`) | `dhh,goal` |
/// | statistics export | a date, the hour, then the counts, with the total last |
///
/// Blank lines, lines starting with `#` and lines that are not numbers are
/// skipped. A statistics file has a header with `Day` in it, which is
/// skipped, and an `Accum` variant with one more column at the end.
/// `fromEnd` picks a band's column of a statistics file, counted back from
/// the total: 1 is 10 m, 2 is 15 m, 3 is 20 m, 4 is 40 m, 5 is 80 m and 6
/// is 160 m. Throws `FormatException` when there is text but no goal in it.
public static Goals Parse(string text, bool isCsv = false, int fromEnd = 0)
{
List<string> lines = [.. text.Split('\n').Select(l => l.TrimEnd('\r'))];
int first = lines.FindIndex(l => !l.StartsWith('#') && l.Trim().Length > 0);
if (first < 0)
{
return Default;
}
char delimiter = isCsv ? ',' : ' ';
bool isStats = false;
bool isAccum = false;
if (!isCsv)
{
string head = lines[first];
isStats = head[..Math.Min(14, head.Length)].Contains("Day", StringComparison.OrdinalIgnoreCase);
isAccum = isStats && head[Math.Max(0, head.Length - 14)..].Contains("Accum", StringComparison.OrdinalIgnoreCase);
if (isAccum)
{
isStats = false;
}
if ((isStats || isAccum || head.StartsWith("Type=GOAL", StringComparison.Ordinal))
&& first + 1 < lines.Count)
{
first++;
}
}
List<(int Hour, int Goal)> found = [];
int dayNumber = 1;
int last = -1;
foreach (string raw in lines.Skip(first))
{
if (raw.StartsWith('#') || raw.Trim().Length == 0)
{
continue;
}
string line = string.Join(' ', raw.Replace('\t', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries));
string[] fields = line.Split(delimiter);
int dayTime;
int goal;
if (isStats || isAccum)
{
int offset = isAccum ? 1 : 0;
if (fields.Length - 1 < 3 + offset || (fromEnd != 0 && fields.Length - 1 < 8 + offset))
{
continue;
}
if (!IsNumber(fields[0].Replace("-", "")))
{
continue;
}
dayTime = Round(fields[1]);
goal = Round(fields[fields.Length - 1 - offset - fromEnd]);
}
else
{
if (fields.Length < 2 || !IsNumber(fields[0].Trim()) || !IsNumber(fields[1].Trim()))
{
continue;
}
dayTime = Round(fields[0]);
goal = Round(fields[1]);
}
// a bare hour belongs to the day being read, and a key that goes
// back starts the next day
if (dayTime < 100)
{
dayTime += dayNumber * 100;
}
if (dayTime < last)
{
dayTime += 100;
dayNumber++;
}
last = dayTime;
if (dayTime >= 100 && goal >= 0)
{
found.Add((dayTime, goal));
}
}
return found.Count == 0
? throw new FormatException("no goals found: each line is `dhh goal`, such as `112 60`")
: new Goals([.. found.OrderBy(e => e.Hour)], isDefault: false);
}
/// The text N1MM's Edit and Export Goals writes: a header, then `dhh goal`.
public string ToText()
{
StringBuilder text = new("Type=GOAL SubType=\n");
foreach ((int hour, int goal) in entries)
{
text.Append(hour).Append(' ').Append(goal).Append('\n');
}
return text.ToString();
}
/// The goal for the hour `nowUtc` falls in. Before the contest it is the
/// goal for the first hour, and 96 hours after the start it is nothing.
public int At(DateTime nowUtc, DateTime startUtc)
{
DateTime thisMinute = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, nowUtc.Hour, nowUtc.Minute, 0);
DateTime startMinute = new(startUtc.Year, startUtc.Month, startUtc.Day, startUtc.Hour, startUtc.Minute, 0);
long minutes = Math.Clamp((long)(thisMinute - startMinute).TotalMinutes, 0, ContestMinutes);
minutes += startUtc.Hour * 60;
int day = (int)(minutes / 1440);
minutes -= day * 1440;
day++;
return day > 4 ? 0 : GoalFor((day * 100) + (int)(minutes / 60));
}
private int GoalFor(int key)
{
int goal = 0;
foreach ((int hour, int value) in entries)
{
if (hour <= key)
{
goal = value;
}
if (hour == key)
{
break;
}
}
return goal;
}
private static bool IsNumber(string text) =>
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _);
private static int Round(string text) =>
(int)Math.Round(double.Parse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture));
}
+179
View File
@@ -0,0 +1,179 @@
using Nonemm.Core;
namespace Nonemm.Contests;
/// The seven things N1MM's QSO timer box can show.
public enum QsoTimerOption
{
/// What is left of the minimum off time, counted from the minute after
/// the last contact.
CountDown = 0,
/// The time since the minute after the last contact. It turns green when
/// the minimum off time is reached.
CountUp = 1,
/// The time off in the whole log, counting only breaks of the minimum
/// off time or more.
Cumulative = 2,
/// The time since the last contact on any computer or radio.
AllStations = 3,
/// The time since the last contact on this computer, either radio.
ThisStation = 4,
LeftRadio = 5,
RightRadio = 6,
}
public enum TimerState
{
Plain,
Met,
Warning,
Over,
}
/// What the QSO timer box says and which colour it is.
public sealed record QsoTimerReading(string Text, TimerState State);
public static class QsoTimer
{
private const int SecondsInDay = 86_400;
/// Where the display changes from `mm:ss` to `hh:mm:ss`.
private const int LongDisplayAfterSeconds = 3540;
private static readonly QsoTimerReading NoContacts = new("0:00", TimerState.Plain);
/// `contacts` is every contact in the log, whoever made it. The first
/// three options only look at the rest of the log; the last four limit it to
/// `computer` and a radio as their names say.
public static QsoTimerReading Read(
IReadOnlyList<Qso> contacts,
QsoTimerOption option,
TimeSpan minimumOff,
DateTime contestStartUtc,
DateTime nowUtc,
string computer)
{
if (contacts.Count == 0)
{
return NoContacts;
}
return option switch
{
QsoTimerOption.CountDown or QsoTimerOption.CountUp => Interval(contacts, option, minimumOff, nowUtc),
QsoTimerOption.Cumulative => new QsoTimerReading(
Clock(CumulativeOffMinutes(contacts, minimumOff, contestStartUtc) * 60),
TimerState.Plain),
_ => SinceLast(contacts, option, computer, nowUtc),
};
}
/// The label N1MM puts on the box for each option.
public static string Caption(QsoTimerOption option, int minimumOffMinutes, bool isSo2r) => option switch
{
QsoTimerOption.CountDown => $"Off Time v ({minimumOffMinutes})",
QsoTimerOption.CountUp => $"Off Time ^ ({minimumOffMinutes})",
QsoTimerOption.Cumulative => $"Contest Off Time ({minimumOffMinutes})",
QsoTimerOption.AllStations => "Last QSO",
QsoTimerOption.ThisStation => isSo2r ? "Both Radios" : "Both VFOs",
QsoTimerOption.LeftRadio => isSo2r ? "Left Radio" : "VFO A",
QsoTimerOption.RightRadio => isSo2r ? "Right Radio" : "VFO B",
_ => throw new ArgumentOutOfRangeException(nameof(option), option, "unknown timer option"),
};
/// The whole minutes of break between contacts that are `minimumOff` or
/// longer, counted from the contest start. A break runs from the minute
/// after one contact to the minute of the next.
public static long CumulativeOffMinutes(
IEnumerable<Qso> contacts,
TimeSpan minimumOff,
DateTime contestStartUtc)
{
long total = 0;
DateTime? before = null;
foreach (DateTime at in contacts.Select(q => q.TimestampUtc).Where(t => t >= contestStartUtc).Order())
{
if (before is { } earlier)
{
long gap = (long)(FloorMinute(at) - NextMinute(earlier)).TotalMinutes;
if (gap >= minimumOff.TotalMinutes)
{
total += gap;
}
}
before = at;
}
return total;
}
private static QsoTimerReading Interval(
IReadOnlyList<Qso> contacts,
QsoTimerOption option,
TimeSpan minimumOff,
DateTime nowUtc)
{
DateTime last = contacts.Max(q => q.TimestampUtc);
long minimum = (long)minimumOff.TotalSeconds;
long seconds = (long)(FloorMinute(nowUtc) - NextMinute(last)).TotalSeconds;
if (option == QsoTimerOption.CountUp)
{
seconds = Math.Max(seconds, 0);
if (seconds >= SecondsInDay)
{
return new QsoTimerReading("", TimerState.Plain);
}
return new QsoTimerReading(Clock(seconds), seconds >= minimum ? TimerState.Met : TimerState.Plain);
}
long left = Math.Min(minimum, Math.Max(minimum - seconds, 0));
return new QsoTimerReading(Clock(left), left == 0 ? TimerState.Met : TimerState.Plain);
}
private static QsoTimerReading SinceLast(
IReadOnlyList<Qso> contacts,
QsoTimerOption option,
string computer,
DateTime nowUtc)
{
IEnumerable<Qso> scope = option switch
{
QsoTimerOption.ThisStation => contacts.Where(q => q.StationName == computer),
QsoTimerOption.LeftRadio => contacts.Where(q => q.StationName == computer && q.RadioNumber == 1),
QsoTimerOption.RightRadio => contacts.Where(q => q.StationName == computer && q.RadioNumber == 2),
_ => contacts,
};
DateTime? last = scope.Select(q => (DateTime?)q.TimestampUtc).Max();
if (last is not { } at)
{
return NoContacts;
}
long seconds = (long)(nowUtc - at).TotalSeconds;
string text = seconds switch
{
< 0 => "0:00",
>= SecondsInDay => "> 24hrs",
<= LongDisplayAfterSeconds => $"{seconds / 60 % 60:00}:{seconds % 60:00}",
_ => $"{seconds / 3600:00}:{seconds / 60 % 60:00}:{seconds % 60:00}",
};
TimerState state = seconds switch
{
< 60 => TimerState.Plain,
<= 180 => TimerState.Warning,
_ => TimerState.Over,
};
return new QsoTimerReading(text, state);
}
/// `hh:mm`, which is how the interval timers show seconds.
private static string Clock(long seconds) => $"{seconds / 3600:00}:{seconds / 60 % 60:00}";
private static DateTime FloorMinute(DateTime time) =>
new(time.Year, time.Month, time.Day, time.Hour, time.Minute, 0, time.Kind);
/// The minute that starts after `time`, even when `time` is on the minute.
private static DateTime NextMinute(DateTime time) => FloorMinute(time).AddMinutes(1);
}
+75
View File
@@ -0,0 +1,75 @@
using Nonemm.Core;
namespace Nonemm.Contests;
/// The figures behind the two rate graphs in N1MM's info window.
public static class RateBars
{
/// Contacts an hour in each of `bars` stretches of `minutes`, oldest first.
/// The stretches follow the clock: 20 minutes starts on the hour, at :20 and
/// at :40. The last one holds the contacts so far and is usually not over,
/// so its figure is those scaled up to an hour.
public static IReadOnlyList<int> Hourly(
IEnumerable<Qso> qsos,
DateTime nowUtc,
int minutes,
int bars = 5)
{
TimeSpan length = TimeSpan.FromMinutes(minutes);
int sinceHour = (nowUtc.Minute / minutes) * minutes;
DateTime current = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, nowUtc.Hour, 0, 0, nowUtc.Kind);
current = current.AddMinutes(sinceHour);
int[] counts = new int[bars];
foreach (Qso qso in qsos)
{
if (qso.TimestampUtc > nowUtc || qso.TimestampUtc >= current + length)
{
continue;
}
int back = (int)Math.Ceiling((current + length - qso.TimestampUtc) / length) - 1;
if (back < bars)
{
counts[bars - 1 - back]++;
}
}
return [.. counts.Select(c => (int)Math.Round(c * 60.0 / minutes))];
}
/// The contacts made in the `span` before `nowUtc`.
public static int Within(IEnumerable<Qso> qsos, DateTime nowUtc, TimeSpan span) =>
qsos.Count(q => q.TimestampUtc <= nowUtc && nowUtc - q.TimestampUtc < span);
public static int PointsWithin(IEnumerable<Qso> qsos, DateTime nowUtc, TimeSpan span) =>
qsos.Where(q => q.TimestampUtc <= nowUtc && nowUtc - q.TimestampUtc < span).Sum(q => q.Points);
/// The contacts made since the top of the current clock hour.
public static int ThisClockHour(IEnumerable<Qso> qsos, DateTime nowUtc) =>
Since(qsos, nowUtc).Count();
public static int PointsThisClockHour(IEnumerable<Qso> qsos, DateTime nowUtc) =>
Since(qsos, nowUtc).Sum(q => q.Points);
/// The points of the last `count` contacts before `nowUtc`, stopping at the
/// first gap of `minimumOff` as `Rates.PerHour` does.
public static int PointsOfLast(IEnumerable<Qso> qsos, int count, DateTime nowUtc, TimeSpan minimumOff)
{
DateTime oldest = nowUtc;
int points = 0;
foreach (Qso qso in qsos.OrderByDescending(q => q.TimestampUtc).Take(count))
{
if (oldest - qso.TimestampUtc > minimumOff)
{
break;
}
oldest = qso.TimestampUtc;
points += qso.Points;
}
return points;
}
private static IEnumerable<Qso> Since(IEnumerable<Qso> qsos, DateTime nowUtc)
{
DateTime top = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, nowUtc.Hour, 0, 0, nowUtc.Kind);
return qsos.Where(q => q.TimestampUtc >= top && q.TimestampUtc <= nowUtc);
}
}
+96
View File
@@ -0,0 +1,96 @@
using System.Globalization;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Spotting;
namespace Nonemm.Session;
/// The sun line of the info window. `IsSoon` is true when the sun rises or
/// sets within the hour, which N1MM writes in another colour.
public sealed record SunLine(string Text, bool IsSoon);
/// The lines of text in N1MM's info window, in N1MM's words.
public static class InfoLines
{
private const double MilesPerKilometre = 0.621371192237334;
/// Countries where the other station's time is left out of the sun line,
/// because they have more than one time zone.
private static readonly string[] ManyTimeZones = ["K", "VK", "UA", "VE"];
/// `OM: EU/Slovak Republic, Zn 15`. The zone is the one the contest counts.
public static string Country(CountryLookup country, bool usesItuZones) =>
$"{country.Entity.PrimaryPrefix}: {country.Continent}/{country.Entity.Name}, Zn "
+ (usesItuZones ? country.ItuZone : country.CqZone).ToString(CultureInfo.InvariantCulture);
/// `Hdg 38° LP 218° 5678mi 9156km`: the short-path heading, the long-path
/// heading and the distance. A station in North America sees miles first.
public static string Bearing(StationPath path, bool isNorthAmerica)
{
string miles = (path.DistanceKm * MilesPerKilometre).ToString("0", CultureInfo.InvariantCulture);
string kilometres = path.DistanceKm.ToString("0", CultureInfo.InvariantCulture);
string distance = isNorthAmerica ? $"{miles}mi {kilometres}km" : $"{kilometres}km {miles}mi";
return $"Hdg {path.Heading:0}° LP {path.LongPath:0}° {distance}";
}
/// `Sunrise:05:12Z Sunset:19:40Z`, with `[Sunrise in 20 minutes]` when it
/// is within the hour, and the time there, except where a country spans
/// several zones.
public static SunLine Sun(CountryLookup country, DateTime nowUtc)
{
if (SunTimes.For(country.Latitude, country.Longitude, nowUtc) is not { } sun)
{
return new SunLine("no sunrise or sunset today", false);
}
string text = $"Sunrise:{sun.Rise:HH:mm}Z Sunset:{sun.Set:HH:mm}Z";
bool isSoon = false;
foreach ((string name, DateTime at) in new[] { ("Sunrise", sun.Rise), ("Sunset", sun.Set) })
{
double minutes = MinutesUntil(at, nowUtc);
if (Math.Abs(minutes) >= 60)
{
continue;
}
isSoon = true;
string count = Math.Abs(minutes).ToString("0", CultureInfo.InvariantCulture);
text += minutes >= 0 ? $" [{name} in {count} minutes]" : $" [{name} was {count} minutes ago]";
}
if (!ManyTimeZones.Contains(country.Entity.PrimaryPrefix))
{
// the country file counts hours west of Greenwich as positive
DateTime local = nowUtc.AddHours(-country.Entity.UtcOffset);
text += $" His time: {local:HHmm}({local.ToString("ddd", CultureInfo.InvariantCulture)}) ";
}
return new SunLine(text, isSoon);
}
/// `K1ABC - 14032.3 [W8WWV-# @ -3 min] - CW 25 dB`: the spot with the
/// whole minutes from now to the minute it was made.
public static string Spot(Spot spot, DateTime nowUtc)
{
double kilohertz = Math.Round(spot.Frequency.Kilohertz, 2);
int minutes = (int)(Minute(spot.AtUtc) - Minute(nowUtc)).TotalMinutes;
string text = $"{spot.Call.Text} - {kilohertz.ToString(CultureInfo.InvariantCulture)} "
+ $"[{spot.Spotter} @ {minutes} min]";
return spot.Comment.Trim().Length > 0 ? $"{text} - {spot.Comment.Trim()}" : text;
}
/// Minutes from now to a time of day, wrapped to the nearest day so the
/// sunrise at 00:10 is 20 minutes away at 23:50.
private static double MinutesUntil(DateTime at, DateTime nowUtc)
{
double minutes = (at.TimeOfDay - nowUtc.TimeOfDay).TotalMinutes;
if (minutes > 1380)
{
minutes -= 1440;
}
else if (minutes < -1380)
{
minutes += 1440;
}
return minutes;
}
private static DateTime Minute(DateTime time) =>
new(time.Year, time.Month, time.Day, time.Hour, time.Minute, 0, time.Kind);
}
@@ -0,0 +1,274 @@
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
/// The figures the info window shows: rate bars, goals and the QSO timers.
public class InfoWindowTests
{
private static readonly DateTime Now = new(2026, 5, 30, 12, 20, 0, DateTimeKind.Utc);
private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Off = TimeSpan.FromMinutes(30);
private static Qso MinutesAgo(int minutes, string computer = "A", int radio = 1) =>
TestLog.Contact("OM3XYZ") with
{
TimestampUtc = Now.AddMinutes(-minutes),
StationName = computer,
RadioNumber = radio,
};
private static QsoTimerReading Read(
IReadOnlyList<Qso> log,
QsoTimerOption option,
DateTime? now = null) =>
QsoTimer.Read(log, option, Off, Start, now ?? Now, "A");
[Fact]
public void HourlyBarsFollowTheClockAndScaleEachStretchToAnHour()
{
// 12:20 starts a 20-minute stretch: 12:20 is in the last bar, 12:19 and
// 12:05 in the one before, and 11:59 in the one before that
List<Qso> log = [MinutesAgo(0), MinutesAgo(1), MinutesAgo(15), MinutesAgo(21)];
IReadOnlyList<int> bars = RateBars.Hourly(log, Now, 20);
Assert.Equal([0, 0, 3, 6, 3], bars);
}
[Fact]
public void HourlyBarsLeaveOutContactsOlderThanTheGraph()
{
Assert.All(RateBars.Hourly([MinutesAgo(500)], Now, 20), bar => Assert.Equal(0, bar));
}
[Fact]
public void HourlyBarsLeaveOutContactsAfterNow()
{
Assert.All(RateBars.Hourly([MinutesAgo(-5)], Now, 60), bar => Assert.Equal(0, bar));
}
[Fact]
public void TheClockHourStartsAtTheTopOfTheHour()
{
List<Qso> log = [MinutesAgo(5), MinutesAgo(19), MinutesAgo(21)];
Assert.Equal(2, RateBars.ThisClockHour(log, Now));
}
[Fact]
public void PointsAreSummedOverTheLastContacts()
{
List<Qso> log = [.. Enumerable.Range(1, 5).Select(m => MinutesAgo(m) with { Points = 3 })];
Assert.Equal(9, RateBars.PointsOfLast(log, 3, Now, Off));
}
[Fact]
public void AGoalStaysInForceUntilTheNextOne()
{
Goals goals = Goals.Parse("100 60\n118 90\n");
Assert.Equal(60, goals.At(Start.AddHours(5), Start));
Assert.Equal(90, goals.At(Start.AddHours(6), Start));
}
[Fact]
public void GoalsAreFoundByContestDayAndClockHour()
{
Goals goals = Goals.Parse("112 80\n113 70\n201 20\n");
Assert.Equal(80, goals.At(Start.AddMinutes(20), Start));
Assert.Equal(70, goals.At(Start.AddHours(1), Start));
Assert.Equal(20, goals.At(Start.AddHours(13), Start));
}
[Fact]
public void BeforeTheStartTheGoalIsTheFirstHours()
{
Assert.Equal(80, Goals.Parse("112 80\n").At(Start.AddHours(-5), Start));
}
[Fact]
public void NinetySixHoursAfterTheStartTheGoalIsNothing()
{
Assert.Equal(0, Goals.Parse("112 80\n").At(Start.AddHours(96), Start));
}
[Fact]
public void WithNoGoalsTheGoalIsFifty()
{
Assert.Equal(50, Goals.Default.At(Now, Start));
Assert.Equal(50, Goals.Parse("# nothing here\n").At(Now, Start));
}
[Fact]
public void AHourOnItsOwnBelongsToTheDayBeingRead()
{
// 23 is day 1 and the 2 after it is the next day, because the key went back
Goals goals = Goals.Parse("22 10\n23 20\n2 30\n");
Assert.Equal(30, goals.At(Start.AddHours(14), Start));
}
[Fact]
public void ACsvFileHasOneCommaPerLine()
{
Goals goals = Goals.Parse("112,45\n113,55\n", isCsv: true);
Assert.Equal(55, goals.At(Start.AddHours(1), Start));
}
[Fact]
public void ATypeGoalHeaderIsSkipped()
{
Goals goals = Goals.Parse("Type=GOAL SubType=\n112 45\n");
Assert.Equal(45, goals.At(Start, Start));
}
[Fact]
public void AStatisticsFileTakesTheTotalColumn()
{
string file = "Day Hour 160 80 40 20 15 10 Total\n20260530 12 0 0 5 20 1 0 26\n20260530 13 0 0 4 30 2 0 36\n";
Goals goals = Goals.Parse(file);
Assert.Equal(36, goals.At(Start.AddHours(1), Start));
}
[Fact]
public void AStatisticsFileCanTakeOneBandsColumn()
{
string file = "Day Hour 160 80 40 20 15 10 Total\n20260530 12 0 0 5 20 1 0 26\n";
// 3 back from the total is 20 m
Assert.Equal(20, Goals.Parse(file, fromEnd: 3).At(Start, Start));
}
[Fact]
public void GoalsWrittenOutCanBeReadBack()
{
Goals goals = Goals.Parse("112 80\n201 40\n");
Assert.Equal("Type=GOAL SubType=\n112 80\n201 40\n", Goals.Parse(goals.ToText()).ToText());
}
[Fact]
public void TextWithNoGoalInItIsAnError()
{
Assert.Throws<FormatException>(() => Goals.Parse("abc\ndef\n"));
}
[Fact]
public void TheCountUpTimerCountsFromTheMinuteAfterTheLastContact()
{
QsoTimerReading reading = Read([MinutesAgo(5)], QsoTimerOption.CountUp);
// 12:15, so the count starts at 12:16 and now is 12:20
Assert.Equal("00:04", reading.Text);
Assert.Equal(TimerState.Plain, reading.State);
}
[Fact]
public void TheCountUpTimerIsMetAtTheMinimumOffTime()
{
Assert.Equal(TimerState.Met, Read([MinutesAgo(32)], QsoTimerOption.CountUp).State);
}
[Fact]
public void TheCountDownTimerShowsWhatIsLeft()
{
Qso last = TestLog.Contact("OM3XYZ") with { TimestampUtc = Now.AddMinutes(-10).AddSeconds(40) };
QsoTimerReading reading = Read([last], QsoTimerOption.CountDown);
// the contact was at 12:10:40, so the count starts at 12:11 and 9 minutes have gone
Assert.Equal("00:21", reading.Text);
}
[Fact]
public void TheCountDownTimerIsMetAtZero()
{
QsoTimerReading reading = Read([MinutesAgo(40)], QsoTimerOption.CountDown);
Assert.Equal("00:00", reading.Text);
Assert.Equal(TimerState.Met, reading.State);
}
[Fact]
public void TheCumulativeTimerCountsOnlyBreaksOfTheMinimumOrMore()
{
// breaks of 100 and 60 minutes count, and the gap of 10 minutes does not
List<Qso> log = [MinutesAgo(220), MinutesAgo(120), MinutesAgo(60), MinutesAgo(50)];
// 08:40 to 10:20 leaves 99 whole minutes and 10:20 to 11:20 leaves 59
QsoTimerReading reading = QsoTimer.Read(
log,
QsoTimerOption.Cumulative,
Off,
Now.AddHours(-6),
Now,
"A");
Assert.Equal("02:38", reading.Text);
}
[Fact]
public void TimeSinceTheLastContactIsShownInMinutesAndSeconds()
{
QsoTimerReading reading = Read([MinutesAgo(2)], QsoTimerOption.AllStations);
Assert.Equal("02:00", reading.Text);
Assert.Equal(TimerState.Warning, reading.State);
}
[Fact]
public void TimeSinceTheLastContactTurnsRedAfterThreeMinutes()
{
Assert.Equal(TimerState.Over, Read([MinutesAgo(4)], QsoTimerOption.AllStations).State);
}
[Fact]
public void AnHourSinceTheLastContactAddsTheHours()
{
Assert.Equal("01:05:00", Read([MinutesAgo(65)], QsoTimerOption.AllStations).Text);
}
[Fact]
public void ADayWithoutAContactIsSaidSo()
{
Assert.Equal("> 24hrs", Read([MinutesAgo(1500)], QsoTimerOption.AllStations).Text);
}
[Fact]
public void ThisStationLeavesOutContactsFromOtherComputers()
{
List<Qso> log = [MinutesAgo(1, computer: "B"), MinutesAgo(10)];
Assert.Equal("10:00", Read(log, QsoTimerOption.ThisStation).Text);
}
[Fact]
public void TheRightRadioLooksOnlyAtRadioTwo()
{
List<Qso> log = [MinutesAgo(1, radio: 1), MinutesAgo(7, radio: 2)];
Assert.Equal("07:00", Read(log, QsoTimerOption.RightRadio).Text);
}
[Fact]
public void ALogWithNoContactsShowsNoTime()
{
Assert.Equal("0:00", Read([], QsoTimerOption.AllStations).Text);
}
[Fact]
public void TheBoxIsLabelledForTheRadiosTheStationHas()
{
Assert.Equal("Both Radios", QsoTimer.Caption(QsoTimerOption.ThisStation, 30, isSo2r: true));
Assert.Equal("VFO B", QsoTimer.Caption(QsoTimerOption.RightRadio, 30, isSo2r: false));
Assert.Equal("Off Time v (30)", QsoTimer.Caption(QsoTimerOption.CountDown, 30, isSo2r: false));
}
}
@@ -0,0 +1,81 @@
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Spotting;
namespace Nonemm.Session.Tests;
public class InfoLinesTests
{
private static readonly CountryFile Countries = CountryFile.Parse(
"""
Slovak Republic: 15: 28: EU: 48.67: -19.50: -1.0: OM:
OM;
United States: 05: 08: NA: 37.60: 91.87: 5.0: K:
K,W;
""");
[Fact]
public void TheCountryLineNamesTheContinentAndTheZoneTheContestCounts()
{
CountryLookup country = Countries.Find("OM3XYZ")!;
Assert.Equal("OM: EU/Slovak Republic, Zn 15", InfoLines.Country(country, usesItuZones: false));
Assert.Equal("OM: EU/Slovak Republic, Zn 28", InfoLines.Country(country, usesItuZones: true));
}
[Fact]
public void ABearingPutsKilometresFirstOutsideNorthAmerica()
{
StationPath path = new(38, 9156, null, null);
Assert.Equal("Hdg 38° LP 218° 9156km 5689mi", InfoLines.Bearing(path, isNorthAmerica: false));
Assert.Equal("Hdg 38° LP 218° 5689mi 9156km", InfoLines.Bearing(path, isNorthAmerica: true));
}
[Fact]
public void ASunriseWithinTheHourIsSaidSo()
{
CountryLookup country = Countries.Find("OM3XYZ")!;
(DateTime rise, _) = SunTimes.For(country.Latitude, country.Longitude, new DateTime(2026, 8, 28))!.Value;
SunLine line = InfoLines.Sun(country, rise.AddMinutes(-20));
Assert.True(line.IsSoon);
Assert.Contains("[Sunrise in 20 minutes]", line.Text);
}
[Fact]
public void ASunsetThatHasJustPassedIsSaidSo()
{
CountryLookup country = Countries.Find("OM3XYZ")!;
(_, DateTime set) = SunTimes.For(country.Latitude, country.Longitude, new DateTime(2026, 8, 28))!.Value;
SunLine line = InfoLines.Sun(country, set.AddMinutes(10));
Assert.Contains("[Sunset was 10 minutes ago]", line.Text);
}
[Fact]
public void HisTimeIsLeftOutForACountryWithManyTimeZones()
{
DateTime noon = new(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc);
Assert.Contains("His time: 1300(Fri)", InfoLines.Sun(Countries.Find("OM3XYZ")!, noon).Text);
Assert.DoesNotContain("His time", InfoLines.Sun(Countries.Find("W1AW")!, noon).Text);
}
[Fact]
public void ASpotShowsHowManyMinutesAgoItWasMade()
{
DateTime now = new(2026, 8, 28, 12, 10, 30, DateTimeKind.Utc);
Spot spot = new(
Callsign.Parse("K1ABC"),
Frequency.FromKilohertz(14032.3),
now.AddMinutes(-3),
SpotSource.Cluster,
"W8WWV-#",
"CW 25 dB");
Assert.Equal("K1ABC - 14032.3 [W8WWV-# @ -3 min] - CW 25 dB", InfoLines.Spot(spot, now));
}
}