From cf39b5af9cc3bb4d28fec7e0db8c437f827565a4 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 24 Oct 2026 12:17:44 +0000 Subject: [PATCH] Show only the newest SFI, A and K in the solar weather bar For sh/wwv, only the first record under the header is used, since the table lists the newest first. The date, time, forecast and logger are dropped, and the status bar reads "SFI 140 A 5 K 2". The line the node sent is the tool tip. Announcement lines give the same three values. Co-Authored-By: Claude Sonnet 5.5 --- src/Nonemm.App/AppSession.cs | 14 ++--- src/Nonemm.App/Windows/BandmapWindow.axaml.cs | 8 ++- src/Nonemm.Spotting/ClusterClient.cs | 4 +- src/Nonemm.Spotting/SolarReader.cs | 63 ++++++++++++++----- .../Nonemm.Spotting.Tests/SolarReaderTests.cs | 40 +++++++++--- 5 files changed, 93 insertions(+), 36 deletions(-) diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index a3da437..9a26178 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -914,7 +914,7 @@ public sealed partial class AppSession : IDisposable public void ConnectCluster() { cluster?.Dispose(); - SetSolar(""); + SetSolar(null); cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, @@ -924,20 +924,20 @@ public sealed partial class AppSession : IDisposable autoLogon: Settings.ClusterAutoLogon, keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes))); cluster.SpotArrived += (_, spot) => TakeSpot(spot); - cluster.SolarArrived += (_, text) => SetSolar(text); + cluster.SolarArrived += (_, report) => SetSolar(report); cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.Start(); } - /// The latest solar weather the cluster sent, or empty when it has sent + /// The latest solar weather the cluster sent, or null when it has sent /// none on this connection. - public string Solar { get; private set; } = ""; + public SolarReport? Solar { get; private set; } public event EventHandler? SolarChanged; - private void SetSolar(string text) + private void SetSolar(SolarReport? report) { - Solar = text; + Solar = report; SolarChanged?.Invoke(this, EventArgs.Empty); } @@ -945,7 +945,7 @@ public sealed partial class AppSession : IDisposable { cluster?.Dispose(); cluster = null; - SetSolar(""); + SetSolar(null); Changed?.Invoke(this, EventArgs.Empty); } diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs index 94225a8..0123748 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs @@ -1,3 +1,4 @@ +using Avalonia.Controls; using Nonemm.App.Configuration; using Avalonia.Media; using Avalonia.Threading; @@ -84,11 +85,12 @@ public sealed partial class BandmapWindow : RefreshableWindow } /// The status bar holds the solar weather the cluster sent, and is hidden - /// while there is none. + /// while there is none. The line the node sent is its tool tip. private void ShowSolar() { - SolarText.Text = session.Solar; - SolarBar.IsVisible = session.Solar.Length > 0; + SolarText.Text = session.Solar?.Text ?? ""; + ToolTip.SetTip(SolarBar, session.Solar?.Original); + SolarBar.IsVisible = session.Solar is not null; } private void SaveView() diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs index 16bae39..ef9b48d 100644 --- a/src/Nonemm.Spotting/ClusterClient.cs +++ b/src/Nonemm.Spotting/ClusterClient.cs @@ -81,8 +81,8 @@ public sealed class ClusterClient : IDisposable public event EventHandler? LineArrived; - /// The node sent solar weather, as the text to show. - public event EventHandler? SolarArrived; + /// The node sent solar weather. + public event EventHandler? SolarArrived; /// What went out to the node, so the telnet window can show the login and /// the commands beside the node's answers. diff --git a/src/Nonemm.Spotting/SolarReader.cs b/src/Nonemm.Spotting/SolarReader.cs index 25d13fa..09114ab 100644 --- a/src/Nonemm.Spotting/SolarReader.cs +++ b/src/Nonemm.Spotting/SolarReader.cs @@ -2,19 +2,22 @@ using System.Text.RegularExpressions; namespace Nonemm.Spotting; +/// Solar weather as the bandmap shows it: `Text` is the short form and +/// `Original` is the line the node sent. +public sealed record SolarReport(string Text, string Original); + /// Picks the solar weather out of a node's traffic, as N1MM does for its Info /// window. A node sends it as one line, such as `WWV de VE7CC <18Z> : SFI=140, /// A=5, K=2`, or after `sh/wwv` as a header line (`Date Hour SFI A K ...`) -/// and a line of numbers under it. The text is kept as the node sent it, -/// without the extra blanks. +/// above a table of records, newest first. Only the first record is used. public sealed partial class SolarReader { private bool afterHeader; - /// The text to show when the line carries solar weather, otherwise null. - /// A line has to be passed in even when it is not solar weather, because - /// a spot between the header and the numbers cancels the header. - public string? Read(string line) + /// The report when the line carries solar weather, otherwise null. A line + /// has to be passed in even when it is not solar weather, because a spot + /// between the header and the table cancels the header. + public SolarReport? Read(string line) { string text = Collapse(line); if (text.Length == 0) @@ -22,7 +25,6 @@ public sealed partial class SolarReader return null; } string upper = text.ToUpperInvariant(); - bool isFromNode = upper.Contains(" DE ", StringComparison.Ordinal); if (upper.StartsWith("DX", StringComparison.Ordinal)) { afterHeader = false; @@ -33,28 +35,59 @@ public sealed partial class SolarReader || upper.Contains("WWV DE", StringComparison.Ordinal) || upper.Contains("WCY DE", StringComparison.Ordinal)) { - return text; + afterHeader = false; + return Announcement(text); } if (upper.StartsWith("DATE HOUR SFI", StringComparison.Ordinal)) { afterHeader = true; return null; } - if (afterHeader && !isFromNode) + if (!afterHeader) { - return "SFI/A/K: " + text; + return null; } - if (isFromNode) - { - afterHeader = false; - } - return null; + // the first record under the header is the newest; the rest are older + afterHeader = false; + return Record(text); } + /// `24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC` as the three + /// indices. + private static SolarReport? Record(string text) + { + Match row = Row().Match(text); + return row.Success + ? new SolarReport( + Indices(row.Groups["sfi"].Value, row.Groups["a"].Value, row.Groups["k"].Value), + text) + : null; + } + + /// The indices out of `SFI=140, A=5, K=2`. A name that is not there is left + /// out, and a line with none of them is not solar weather. + private static SolarReport? Announcement(string text) + { + string sfi = Index("SFI").Match(text).Groups[1].Value; + string a = Index("A").Match(text).Groups[1].Value; + string k = Index("K").Match(text).Groups[1].Value; + return sfi.Length + a.Length + k.Length == 0 ? null : new SolarReport(Indices(sfi, a, k), text); + } + + private static string Indices(string sfi, string a, string k) => + string.Join(" ", new[] { ("SFI", sfi), ("A", a), ("K", k) } + .Where(i => i.Item2.Length > 0) + .Select(i => $"{i.Item1} {i.Item2}")); + + private static Regex Index(string name) => new($@"\b{name}=(\d+)", RegexOptions.IgnoreCase); + /// Control characters out, runs of blanks down to one. private static string Collapse(string line) => Blanks().Replace(new string([.. line.Where(c => !char.IsControl(c) || c == '\t')]), " ").Trim(); [GeneratedRegex(@"\s+")] private static partial Regex Blanks(); + + [GeneratedRegex(@"^\d{1,2}-[A-Za-z]{3}-\d{2,4} \d{4}Z? (?\d+) (?\d+) (?\d+)\b")] + private static partial Regex Row(); } diff --git a/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs b/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs index 690b130..081261a 100644 --- a/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs +++ b/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs @@ -3,21 +3,43 @@ namespace Nonemm.Spotting.Tests; public class SolarReaderTests { [Theory] - [InlineData("WWV de VE7CC <18Z> : SFI=140, A=5, K=2, No Storms -> No Storms\a\a")] - [InlineData("WCY de DK0WCY-1 <19> : K=2 expK=2 A=5 R=0 SFI=140 SA=qui GMF=qui Au=no")] - [InlineData("WWV: SFI=140 A=5 K=2")] - public void AnnouncementLinesAreKeptAsTheNodeSentThem(string line) => - Assert.Equal(line.TrimEnd('\a'), new SolarReader().Read(line)); + [InlineData("WWV de VE7CC <18Z> : SFI=140, A=5, K=2, No Storms -> No Storms\a\a", "SFI 140 A 5 K 2")] + [InlineData("WCY de DK0WCY-1 <19> : K=2 expK=2 A=5 R=0 SFI=140 SA=qui", "SFI 140 A 5 K 2")] + [InlineData("WWV: SFI=140 A=5 K=2", "SFI 140 A 5 K 2")] + public void AnnouncementLinesShowTheirIndices(string line, string text) + { + SolarReport? report = new SolarReader().Read(line); + + Assert.Equal(text, report?.Text); + Assert.Equal(line.TrimEnd('\a'), report?.Original); + } [Fact] - public void TheLineUnderAShWwvHeaderIsLabelled() + public void TheNewestRecordUnderAShWwvHeaderIsShownWithoutDateOrTime() { SolarReader reader = new(); - Assert.Null(reader.Read("Date Hour SFI A K Forecast Logger")); - Assert.Equal("SFI/A/K: 24-Oct-2026 1200Z 140 5 2 No Storms", reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms")); + Assert.Null(reader.Read(" Date Hour SFI A K Forecast Logger")); + SolarReport? newest = reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC"); + + Assert.Equal("SFI 140 A 5 K 2", newest?.Text); + Assert.Equal("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC", newest?.Original); } + [Fact] + public void TheOlderRecordsUnderTheHeaderAreIgnored() + { + SolarReader reader = new(); + reader.Read("Date Hour SFI A K Forecast Logger"); + reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC"); + + Assert.Null(reader.Read("24-Oct-2026 0900Z 138 6 3 No Storms -> No Storms VE7CC")); + } + + [Fact] + public void AnAnnouncementWithoutIndicesIsNotSolarWeather() => + Assert.Null(new SolarReader().Read("WWV de VE7CC <18Z> : no data")); + [Fact] public void ASpotCancelsTheHeader() { @@ -25,7 +47,7 @@ public class SolarReaderTests reader.Read("Date Hour SFI A K Forecast Logger"); reader.Read("DX de K1TTT: 14025.0 JA1XYZ CW 1205Z"); - Assert.Null(reader.Read("some other line")); + Assert.Null(reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC")); } [Fact]