diff --git a/README.md b/README.md index 33e6cf1..986f8bf 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | Contests | CQ WW, CQ WPX, ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | -| Windows | entry, log, check, bandmap, score summary, packet | +| Windows | entry, log, check, bandmap, available mults and Qs, score summary, packet | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | | Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | @@ -149,6 +149,24 @@ cursor in it can hold — the ARRL sections, or the states and provinces. A serial number or a report has no list behind it, so that column stays empty rather than guessing. +### Available mults and Qs + +**View → Available Mults and Qs** lists what is spotted and not worked yet, a +column per band, multipliers first and 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. **Mults only** hides everything that brings no +multiplier. + +A spot carries no exchange, so a station is only known to be a multiplier where +the multiplier follows from the callsign — a DXCC entity — or where the call +history file says what the station sends. That is why CQ WW shows the country +but not the zone: the zone that counts is the one the station sends, and the +country file's guess is wrong for the large countries. A call history file with +a `CQZone` column fills that in. + +A spot carries no mode either. Stations are judged in the mode the radio is in, +so in a mixed-mode contest the answer follows the operator. + ### Radios **Config → Radios** takes a `rigctld` address per radio. Each radio needs its diff --git a/docs/unfinished.md b/docs/unfinished.md index c1e80f5..25a6b73 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -54,8 +54,6 @@ are in the user-defined folder. N1MM ships well over a hundred. Opening a log from a contest not in the registry fails with the contest name, which is the right answer but it is still a wall. -**The Available Mults and Qs window.** Not started. - ## Known rough edges **The log window's column widths** are the grid's own automatic sizing with a @@ -66,6 +64,12 @@ thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it. That is correct but heavy, and it discards what is typed in an entry window on another radio. +**Available mults and Qs is only as good as the bandmap.** It lists what is +spotted, so a multiplier nobody has spotted is not there. N1MM's window also +shows the mults a contest has and nobody has worked, which needs a list of every +multiplier the contest counts. Only the section and state lists are held here, +so the zone and country lists would have to come from the country file first. + **Two entry windows and one keyer.** The SO2R box is told which radio to key before each message, but nothing stops both radios sending at once if the operator asks them to. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 19ed2bd..da05c1f 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -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 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); } diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml b/src/Nonemm.App/Windows/AvailableWindow.axaml new file mode 100644 index 0000000..387cf07 --- /dev/null +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs new file mode 100644 index 0000000..d4b8127 --- /dev/null +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs @@ -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 tune; + + public AvailableWindow(AppSession session, Action 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 stations = session.Available.All() + .Where(s => !multsOnly || s.IsMultiplier) + .ToList(); + List 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 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}"; + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 5d50ac3..3352bd6 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -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)); diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index df470d3..a302858 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -47,6 +47,7 @@ + diff --git a/src/Nonemm.Session/AvailableStations.cs b/src/Nonemm.Session/AvailableStations.cs new file mode 100644 index 0000000..c1ddaa4 --- /dev/null +++ b/src/Nonemm.Session/AvailableStations.cs @@ -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 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 All() + { + List 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); + } +} diff --git a/src/Nonemm.Session/ExchangeSlots.cs b/src/Nonemm.Session/ExchangeSlots.cs new file mode 100644 index 0000000..c8a7beb --- /dev/null +++ b/src/Nonemm.Session/ExchangeSlots.cs @@ -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 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; +} diff --git a/src/Nonemm.Session/RadioPosition.cs b/src/Nonemm.Session/RadioPosition.cs index bb5046e..664b1fa 100644 --- a/src/Nonemm.Session/RadioPosition.cs +++ b/src/Nonemm.Session/RadioPosition.cs @@ -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; } diff --git a/tests/Nonemm.Session.Tests/AvailableStationsTests.cs b/tests/Nonemm.Session.Tests/AvailableStationsTests.cs new file mode 100644 index 0000000..abf252c --- /dev/null +++ b/tests/Nonemm.Session.Tests/AvailableStationsTests.cs @@ -0,0 +1,170 @@ +using Nonemm.Contests; +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Calls; +using Nonemm.Core.Country; +using Nonemm.Spotting; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +public class AvailableStationsTests +{ + private const string Countries = """ + Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL: + DL,DK,DJ; + Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA: + JA,JH,JR; + United States: 05: 08: NA: 37.53: 91.67: 5.0: K: + K,W,N; + """; + + private static readonly StationInfo Me = new() + { + Callsign = "DL1ABC", + CqZone = 14, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "DL", + }; + + private static readonly Frequency On20 = Frequency.FromKilohertz(14_030); + + private static (RadioPosition Position, Bandmap Bandmap, AvailableStations Available) Station( + Contest? contest = null, + CallHistory? history = null) + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "CQWW", + }); + ContestSession session = new( + store, + contest ?? new CqWorldWide(ModeCategory.Cw), + instance, + Me, + CountryFile.Parse(Countries), + history); + RadioPosition position = new(session); + Bandmap bandmap = new(); + return (position, bandmap, new AvailableStations(position, bandmap)); + } + + private static Spot At(string call, Frequency where) => + new(Callsign.Parse(call), where, DateTime.UtcNow, SpotSource.Cluster, "OK1TEST"); + + private static void Work(RadioPosition position, string call, string zone, Frequency where) + { + position.Tune(where); + position.Entry.Call = call; + position.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + position.Entry.Set(ExchangeSlot.Zone, zone); + position.LogContact(); + } + + [Fact] + public void ASpottedStationThatIsANewCountryIsAMultiplier() + { + (_, Bandmap bandmap, AvailableStations available) = Station(); + bandmap.Add(At("JA1XYZ", On20)); + + AvailableStation station = Assert.Single(available.All()); + Assert.True(station.IsMultiplier); + Assert.Equal(Bands.Band20M, station.Band); + Assert.Equal("JA", station.MultiplierText); + } + + /// CQ WW counts the zone the station sends, not the one the country file + /// guesses, and a spot carries no exchange. So the zone shows only where + /// the call history file published it. + [Fact] + public void TheCallHistoryZoneBringsInTheZoneMultiplier() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,CQZone + JA1XYZ,25 + """); + (_, Bandmap bandmap, AvailableStations available) = Station(history: history); + bandmap.Add(At("JA1XYZ", On20)); + + AvailableStation station = Assert.Single(available.All()); + Assert.Equal("25, JA", station.MultiplierText); + } + + [Fact] + public void AStationAlreadyWorkedOnThatBandIsLeftOut() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA1XYZ", On20)); + + Assert.Empty(available.All()); + } + + [Fact] + public void TheSameStationOnAnotherBandIsStillWorthWorking() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA1XYZ", Frequency.FromKilohertz(7_030))); + + AvailableStation station = Assert.Single(available.All()); + Assert.Equal(Bands.Band40M, station.Band); + Assert.True(station.IsMultiplier); + } + + [Fact] + public void AStationThatBringsNoMultiplierIsStillOfferedForItsPoints() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA2ABC", On20)); + + AvailableStation station = Assert.Single(available.All()); + Assert.False(station.IsMultiplier); + Assert.True(station.Verdict.Points > 0); + } + + [Fact] + public void MultipliersComeFirst() + { + (RadioPosition position, Bandmap bandmap, AvailableStations available) = Station(); + Work(position, "JA1XYZ", "25", On20); + bandmap.Add(At("JA2ABC", Frequency.FromKilohertz(14_010))); + bandmap.Add(At("W1AW", Frequency.FromKilohertz(14_040))); + + Assert.Equal( + ["W1AW", "JA2ABC"], + available.All().Select(s => s.Spot.Call.Text).ToList()); + } + + /// A spot carries no exchange, so a section contest can only judge a + /// spotted station where the call history file says what it sends. + [Fact] + public void TheCallHistoryFillsTheExchangeOfASpottedStation() + { + CallHistory history = CallHistory.Parse(""" + !!Order!!,Call,Sect + W1AW,CT + """); + (_, Bandmap bandmap, AvailableStations available) = + Station(new Sweepstakes(ModeCategory.Cw), history); + bandmap.Add(At("W1AW", Frequency.FromKilohertz(14_080))); + + AvailableStation station = Assert.Single(available.All()); + Assert.True(station.IsMultiplier); + Assert.Equal("CT", station.MultiplierText); + } + + [Fact] + public void WithNoCallHistoryASpottedStationOfASectionContestIsJustPoints() + { + (_, Bandmap bandmap, AvailableStations available) = Station(new Sweepstakes(ModeCategory.Cw)); + bandmap.Add(At("W1AW", Frequency.FromKilohertz(14_080))); + + AvailableStation station = Assert.Single(available.All()); + Assert.False(station.IsMultiplier); + } +}