Show what is spotted and not worked yet

The Available Mults and Qs window: a column per band, the multipliers at the top
of each column and then the stations that are only points. Clicking one puts the
radio there with the call in the entry window, the same as the bandmap does.
Every spot on the bandmap goes through ContestLog.Judge, and what comes back a
dupe is dropped, so the colours and the ordering agree with every other window.

A spot holds a callsign and a frequency and nothing else. For CQ WW that gives
the country multiplier, which is derived from the call, but not the zone, which
is what the other station sends. Where a call history file has an entry for the
call its exchange is filled into the candidate first, so a published CQZone
column brings the zone in and a Sect column makes Sweepstakes work at all.
Filling the zone from the country file instead was rejected: it is wrong for
every large country, and a multiplier that is not there is better than one that
is not real.

A spot has no mode either, so the candidate is judged in the mode the radio is
in. In a mixed-mode contest that means the answer follows the operator.

RadioPosition kept two private mappings — exchange value into a contact, and
call history field into an exchange box — and both are needed here, so they move
to ExchangeSlots and both callers share them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:01:11 +00:00
parent 1fda1696f2
commit d375d86466
11 changed files with 483 additions and 43 deletions

View File

@@ -69,6 +69,9 @@ public sealed class AppSession : IDisposable
public CheckWindowSources? Check { get; private set; }
/// What is spotted and not worked yet.
public AvailableStations? Available { get; private set; }
/// One entry per configured radio, in radio-number order.
public IReadOnlyList<Radio> Radios => radios;
@@ -109,6 +112,7 @@ public sealed class AppSession : IDisposable
store = SqliteLogStore.Open(path);
Logging = null;
Check = null;
Available = null;
Save(Settings with
{
DatabasePath = path,
@@ -154,6 +158,7 @@ public sealed class AppSession : IDisposable
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Available = new AvailableStations(positions[0], Bandmap);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,15 @@
<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.AvailableWindow"
Title="Available mults and Qs" Width="620" Height="340">
<DockPanel Margin="6">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,6">
<CheckBox Name="MultsOnlyBox" Content="Mults only" />
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
</StackPanel>
<ScrollViewer>
<Grid Name="Columns" />
</ScrollViewer>
</DockPanel>
</local:RefreshableWindow>

View File

@@ -0,0 +1,110 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// What is left to work, a column per band: the multipliers at the top of each
/// column, then the stations that are only points. Clicking one puts the radio
/// there with the call in the entry window, the same as clicking a spot on the
/// bandmap.
public sealed partial class AvailableWindow : RefreshableWindow
{
private readonly AppSession session;
private readonly Action<Frequency, string> tune;
public AvailableWindow(AppSession session, Action<Frequency, string> tune)
{
this.session = session;
this.tune = tune;
InitializeComponent();
MultsOnlyBox.IsCheckedChanged += (_, _) => Refresh();
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
Refresh();
}
public override void Refresh()
{
Columns.Children.Clear();
Columns.ColumnDefinitions.Clear();
if (session.Available is null)
{
StatusText.Text = "no contest is open";
return;
}
session.Bandmap.DropOlderThan(DateTime.UtcNow);
bool multsOnly = MultsOnlyBox.IsChecked == true;
List<AvailableStation> stations = session.Available.All()
.Where(s => !multsOnly || s.IsMultiplier)
.ToList();
List<Band> bands = stations
.Select(s => s.Band)
.Distinct()
.OrderBy(b => b.Low.Hertz)
.ToList();
int at = 0;
foreach (Band band in bands)
{
Columns.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
Control column = BuildColumn(band, stations.Where(s => s.Band == band).ToList());
Grid.SetColumn(column, at++);
Columns.Children.Add(column);
}
int mults = stations.Count(s => s.IsMultiplier);
StatusText.Text = stations.Count == 0
? "nothing spotted that is not worked"
: $"{stations.Count} to work · {mults} multipliers";
}
private Control BuildColumn(Band band, IReadOnlyList<AvailableStation> stations)
{
StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) };
panel.Children.Add(new TextBlock
{
Text = $"{band.Name} {stations.Count}",
FontSize = 11,
Opacity = 0.7,
Margin = new Avalonia.Thickness(0, 0, 0, 4),
});
foreach (AvailableStation station in stations)
{
panel.Children.Add(Row(station));
}
return panel;
}
private Control Row(AvailableStation station)
{
TextBlock text = new()
{
Text = Label(station),
FontFamily = new FontFamily("monospace"),
FontSize = 13,
Foreground = Verdicts.Colour(station.Verdict),
HorizontalAlignment = HorizontalAlignment.Left,
Cursor = new Cursor(StandardCursorType.Hand),
};
ToolTip.SetTip(text, Tip(station));
text.PointerPressed += (_, _) => tune(station.Spot.Frequency, station.Spot.Call.Text);
return text;
}
private static string Label(AvailableStation station)
{
string call = $"{station.Spot.Frequency.Kilohertz,9:0.0} {station.Spot.Call.Text}";
return station.IsMultiplier ? $"{call} · {station.MultiplierText}" : call;
}
private static string Tip(AvailableStation station)
{
string who = station.Spot.Spotter.Length > 0 ? $" de {station.Spot.Spotter}" : "";
return $"{Verdicts.Describe(station.Verdict)}{who}";
}
}

View File

@@ -230,6 +230,9 @@ public sealed partial class EntryWindow
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
private void OnShowAvailable(object? sender, RoutedEventArgs e) =>
Show(() => new AvailableWindow(session, Tune));
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));

View File

@@ -47,6 +47,7 @@
<MenuItem Header="_Log" Click="OnShowLog" />
<MenuItem Header="_Check" Click="OnShowCheck" />
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
<MenuItem Header="_Available Mults and Qs" Click="OnShowAvailable" />
<MenuItem Header="_Score Summary" Click="OnShowScore" />
<MenuItem Header="_Packet" Click="OnShowPacket" />
</MenuItem>

View File

@@ -0,0 +1,80 @@
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Spotting;
namespace Nonemm.Session;
/// One spotted station worth working, and what working it would bring.
public sealed record AvailableStation(Spot Spot, Band Band, Verdict Verdict)
{
public IReadOnlyList<Multiplier> NewMultipliers => Verdict.NewMultipliers;
public bool IsMultiplier => Verdict.NewMultipliers.Count > 0;
/// The multiplier values, e.g. `JA, 25` for a country and a zone.
public string MultiplierText =>
string.Join(", ", Verdict.NewMultipliers.Select(m => m.Value).Distinct(StringComparer.Ordinal));
}
/// What is on the bandmap that this station has not worked: the multipliers
/// first, then the stations that are only points.
///
/// A spot carries no exchange, so a contest scored on what the other station
/// sends — a section, a name — cannot be judged from the spot alone. Where the
/// call history file has an entry for the call, its exchange is used, which is
/// what makes this window worth opening in a domestic contest. Without one the
/// station shows as a plain contact rather than as a multiplier it might be.
///
/// A spot carries no mode either. Contacts are judged in the mode the radio is
/// in, so in a mixed-mode contest the answer follows the operator.
public sealed class AvailableStations
{
private readonly RadioPosition position;
private readonly Bandmap bandmap;
public AvailableStations(RadioPosition position, Bandmap bandmap)
{
this.position = position;
this.bandmap = bandmap;
}
/// Every spotted station that is not a dupe, multipliers first and then in
/// frequency order.
public IReadOnlyList<AvailableStation> All()
{
List<AvailableStation> found = [];
foreach (Spot spot in bandmap.All())
{
if (spot.Band is not { } band)
{
continue;
}
Verdict verdict = position.Log.Judge(Candidate(spot));
if (!verdict.IsDupe)
{
found.Add(new AvailableStation(spot, band, verdict));
}
}
return found
.OrderByDescending(s => s.IsMultiplier)
.ThenBy(s => s.Spot.Frequency.Hertz)
.ToList();
}
private Qso Candidate(Spot spot)
{
Qso qso = new()
{
Id = "",
TimestampUtc = DateTime.UtcNow,
Call = spot.Call,
Frequency = spot.Frequency,
Mode = position.Mode,
ContestName = position.Contest.Name,
};
return ExchangeSlots.ApplyHistory(
CountryFields.Apply(qso, position.Session.Countries),
position.Session.History,
position.Entry.Exchange);
}
}

View File

@@ -0,0 +1,72 @@
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Calls;
namespace Nonemm.Session;
/// Moving exchange values between the entry boxes, the call history file and a
/// contact.
public static class ExchangeSlots
{
public static Qso Apply(Qso qso, ExchangeSlot slot, string value) => slot switch
{
ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value },
ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) },
ExchangeSlot.Zone => qso with { Zone = Number(value) },
ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() },
ExchangeSlot.Check => qso with { Check = Number(value) },
ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() },
ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() },
ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() },
ExchangeSlot.Name => qso with { Name = value },
ExchangeSlot.Qth => qso with { Qth = value },
ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() },
ExchangeSlot.Power => qso with { Power = value },
ExchangeSlot.Comment => qso with { Comment = value },
_ => qso,
};
/// What the call history file says this station sends in one exchange box,
/// or an empty string when the file holds nothing for it.
public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => field.Slot switch
{
ExchangeSlot.Name => known.Name,
// a file for a section contest often carries the state instead
ExchangeSlot.Section => known.Section.Length > 0 ? known.Section : known.State,
ExchangeSlot.Check => Digits(known.Check),
ExchangeSlot.Zone => field.Kind == ExchangeFieldKind.ItuZone
? Digits(known.ItuZone)
: Digits(known.CqZone),
ExchangeSlot.Exchange1 => known.Exchange1,
ExchangeSlot.MiscText => known.MiscText,
ExchangeSlot.GridSquare => known.GridSquare,
ExchangeSlot.Power => known.Power,
ExchangeSlot.Qth => known.State,
_ => "",
};
/// Fills the exchange the call history file published for this station into
/// a contact. Used where nobody has copied the exchange yet: a spotted
/// station the operator has not worked.
public static Qso ApplyHistory(Qso qso, CallHistory history, IReadOnlyList<ExchangeField> exchange)
{
CallHistoryEntry? known = history.Find(qso.Call.Text);
if (known is null)
{
return qso;
}
foreach (ExchangeField field in exchange)
{
string value = ValueFrom(known, field);
if (value.Length > 0)
{
qso = Apply(qso, field.Slot, value);
}
}
return qso;
}
private static string Digits(int value) => value > 0 ? value.ToString() : "";
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
}

View File

@@ -101,7 +101,7 @@ public sealed class RadioPosition
bool filled = false;
for (int at = 0; at < Entry.Exchange.Count; at++)
{
string value = ValueFor(known, Entry.Exchange[at]);
string value = ExchangeSlots.ValueFrom(known, Entry.Exchange[at]);
if (value.Length == 0 || Entry[at + 1].Trim().Length > 0)
{
continue;
@@ -116,25 +116,6 @@ public sealed class RadioPosition
return filled;
}
private static string ValueFor(CallHistoryEntry known, ExchangeField field) => field.Slot switch
{
ExchangeSlot.Name => known.Name,
// a file for a section contest often carries the state instead
ExchangeSlot.Section => known.Section.Length > 0 ? known.Section : known.State,
ExchangeSlot.Check => Digits(known.Check),
ExchangeSlot.Zone => field.Kind == ExchangeFieldKind.ItuZone
? Digits(known.ItuZone)
: Digits(known.CqZone),
ExchangeSlot.Exchange1 => known.Exchange1,
ExchangeSlot.MiscText => known.MiscText,
ExchangeSlot.GridSquare => known.GridSquare,
ExchangeSlot.Power => known.Power,
ExchangeSlot.Qth => known.State,
_ => "",
};
private static string Digits(int value) => value > 0 ? value.ToString() : "";
public void Wipe()
{
Entry.Clear();
@@ -175,27 +156,8 @@ public sealed class RadioPosition
{
foreach (ExchangeField field in Entry.Exchange)
{
string value = Entry.ValueOf(field.Slot).Trim();
qso = field.Slot switch
{
ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value },
ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) },
ExchangeSlot.Zone => qso with { Zone = Number(value) },
ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() },
ExchangeSlot.Check => qso with { Check = Number(value) },
ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() },
ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() },
ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() },
ExchangeSlot.Name => qso with { Name = value },
ExchangeSlot.Qth => qso with { Qth = value },
ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() },
ExchangeSlot.Power => qso with { Power = value },
ExchangeSlot.Comment => qso with { Comment = value },
_ => qso,
};
qso = ExchangeSlots.Apply(qso, field.Slot, Entry.ValueOf(field.Slot).Trim());
}
return qso;
}
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
}