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 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:17:44 +00:00
co-authored by Claude Sonnet 5.5
parent e55159ba49
commit cf39b5af9c
5 changed files with 93 additions and 36 deletions
+7 -7
View File
@@ -914,7 +914,7 @@ public sealed partial class AppSession : IDisposable
public void ConnectCluster() public void ConnectCluster()
{ {
cluster?.Dispose(); cluster?.Dispose();
SetSolar(""); SetSolar(null);
cluster = new ClusterClient( cluster = new ClusterClient(
Settings.ClusterHost, Settings.ClusterHost,
Settings.ClusterPort, Settings.ClusterPort,
@@ -924,20 +924,20 @@ public sealed partial class AppSession : IDisposable
autoLogon: Settings.ClusterAutoLogon, autoLogon: Settings.ClusterAutoLogon,
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes))); keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
cluster.SpotArrived += (_, spot) => TakeSpot(spot); cluster.SpotArrived += (_, spot) => TakeSpot(spot);
cluster.SolarArrived += (_, text) => SetSolar(text); cluster.SolarArrived += (_, report) => SetSolar(report);
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
cluster.Start(); 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. /// none on this connection.
public string Solar { get; private set; } = ""; public SolarReport? Solar { get; private set; }
public event EventHandler? SolarChanged; public event EventHandler? SolarChanged;
private void SetSolar(string text) private void SetSolar(SolarReport? report)
{ {
Solar = text; Solar = report;
SolarChanged?.Invoke(this, EventArgs.Empty); SolarChanged?.Invoke(this, EventArgs.Empty);
} }
@@ -945,7 +945,7 @@ public sealed partial class AppSession : IDisposable
{ {
cluster?.Dispose(); cluster?.Dispose();
cluster = null; cluster = null;
SetSolar(""); SetSolar(null);
Changed?.Invoke(this, EventArgs.Empty); Changed?.Invoke(this, EventArgs.Empty);
} }
@@ -1,3 +1,4 @@
using Avalonia.Controls;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading; 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 /// 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() private void ShowSolar()
{ {
SolarText.Text = session.Solar; SolarText.Text = session.Solar?.Text ?? "";
SolarBar.IsVisible = session.Solar.Length > 0; ToolTip.SetTip(SolarBar, session.Solar?.Original);
SolarBar.IsVisible = session.Solar is not null;
} }
private void SaveView() private void SaveView()
+2 -2
View File
@@ -81,8 +81,8 @@ public sealed class ClusterClient : IDisposable
public event EventHandler<string>? LineArrived; public event EventHandler<string>? LineArrived;
/// The node sent solar weather, as the text to show. /// The node sent solar weather.
public event EventHandler<string>? SolarArrived; public event EventHandler<SolarReport>? SolarArrived;
/// What went out to the node, so the telnet window can show the login and /// What went out to the node, so the telnet window can show the login and
/// the commands beside the node's answers. /// the commands beside the node's answers.
+48 -15
View File
@@ -2,19 +2,22 @@ using System.Text.RegularExpressions;
namespace Nonemm.Spotting; 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 /// 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, /// 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 ...`) /// 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, /// above a table of records, newest first. Only the first record is used.
/// without the extra blanks.
public sealed partial class SolarReader public sealed partial class SolarReader
{ {
private bool afterHeader; private bool afterHeader;
/// The text to show when the line carries solar weather, otherwise null. /// The report when the line carries solar weather, otherwise null. A line
/// A line has to be passed in even when it is not solar weather, because /// has to be passed in even when it is not solar weather, because a spot
/// a spot between the header and the numbers cancels the header. /// between the header and the table cancels the header.
public string? Read(string line) public SolarReport? Read(string line)
{ {
string text = Collapse(line); string text = Collapse(line);
if (text.Length == 0) if (text.Length == 0)
@@ -22,7 +25,6 @@ public sealed partial class SolarReader
return null; return null;
} }
string upper = text.ToUpperInvariant(); string upper = text.ToUpperInvariant();
bool isFromNode = upper.Contains(" DE ", StringComparison.Ordinal);
if (upper.StartsWith("DX", StringComparison.Ordinal)) if (upper.StartsWith("DX", StringComparison.Ordinal))
{ {
afterHeader = false; afterHeader = false;
@@ -33,28 +35,59 @@ public sealed partial class SolarReader
|| upper.Contains("WWV DE", StringComparison.Ordinal) || upper.Contains("WWV DE", StringComparison.Ordinal)
|| upper.Contains("WCY DE", StringComparison.Ordinal)) || upper.Contains("WCY DE", StringComparison.Ordinal))
{ {
return text; afterHeader = false;
return Announcement(text);
} }
if (upper.StartsWith("DATE HOUR SFI", StringComparison.Ordinal)) if (upper.StartsWith("DATE HOUR SFI", StringComparison.Ordinal))
{ {
afterHeader = true; afterHeader = true;
return null; return null;
} }
if (afterHeader && !isFromNode) if (!afterHeader)
{ {
return "SFI/A/K: " + text; return null;
} }
if (isFromNode) // the first record under the header is the newest; the rest are older
{ afterHeader = false;
afterHeader = false; return Record(text);
}
return null;
} }
/// `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. /// Control characters out, runs of blanks down to one.
private static string Collapse(string line) => private static string Collapse(string line) =>
Blanks().Replace(new string([.. line.Where(c => !char.IsControl(c) || c == '\t')]), " ").Trim(); Blanks().Replace(new string([.. line.Where(c => !char.IsControl(c) || c == '\t')]), " ").Trim();
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex Blanks(); private static partial Regex Blanks();
[GeneratedRegex(@"^\d{1,2}-[A-Za-z]{3}-\d{2,4} \d{4}Z? (?<sfi>\d+) (?<a>\d+) (?<k>\d+)\b")]
private static partial Regex Row();
} }
@@ -3,21 +3,43 @@ namespace Nonemm.Spotting.Tests;
public class SolarReaderTests public class SolarReaderTests
{ {
[Theory] [Theory]
[InlineData("WWV de VE7CC <18Z> : SFI=140, A=5, K=2, No Storms -> No Storms\a\a")] [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 GMF=qui Au=no")] [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")] [InlineData("WWV: SFI=140 A=5 K=2", "SFI 140 A 5 K 2")]
public void AnnouncementLinesAreKeptAsTheNodeSentThem(string line) => public void AnnouncementLinesShowTheirIndices(string line, string text)
Assert.Equal(line.TrimEnd('\a'), new SolarReader().Read(line)); {
SolarReport? report = new SolarReader().Read(line);
Assert.Equal(text, report?.Text);
Assert.Equal(line.TrimEnd('\a'), report?.Original);
}
[Fact] [Fact]
public void TheLineUnderAShWwvHeaderIsLabelled() public void TheNewestRecordUnderAShWwvHeaderIsShownWithoutDateOrTime()
{ {
SolarReader reader = new(); SolarReader reader = new();
Assert.Null(reader.Read("Date Hour SFI A K Forecast Logger")); 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")); 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] [Fact]
public void ASpotCancelsTheHeader() public void ASpotCancelsTheHeader()
{ {
@@ -25,7 +47,7 @@ public class SolarReaderTests
reader.Read("Date Hour SFI A K Forecast Logger"); reader.Read("Date Hour SFI A K Forecast Logger");
reader.Read("DX de K1TTT: 14025.0 JA1XYZ CW 1205Z"); 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] [Fact]