Take WAE QTC traffic down
Ctrl+Z opens the QTC window for the station being worked, which is the key N1MM puts it on, and the window is laid out the way N1MM lays it out: the header, ten lines of time, callsign and serial number, an Agn and a Cfm button beside each, Clear, Close and Cancel underneath, and the same colours — green for a line that will be saved, red for one still empty, yellow for one that cannot be read. Enter and Tab move forward and space moves within a line, so a line can be walked while listening to it. Sending, the lines are filled from the log and cannot be edited: the contacts not reported to anybody yet, oldest first, never the station being worked, and never past the ten any station may have. The rule for what has been reported is N1MM's — a contact counts as reported when a QTC row carries its call and the serial we sent it — so the pair is kept in the row the way N1MM keeps it. Which way traffic goes is not the operator's choice on CW and SSB: Europe receives and the rest of the world sends. On RTTY it goes both ways, and there the window carries a switch. That is the one piece of the workflow that is ours rather than N1MM's, which toggles a QTC mode outside the window instead. Nothing here transmits. N1MM sends the lines on CW and RTTY and plays recorded voice messages on SSB; we have no QTC message templates, no voice keyer and no digital window, so Agn, Cfm and RX Ready move the cursor and the operator sends by hand. Running it caught what the tests could not: focus set in the constructor does not stick, so the first thing typed into a freshly opened window was dropped and every field after it took the value of the one before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
18
README.md
18
README.md
@@ -279,8 +279,22 @@ number columns, and the series such as `9/10` goes in the misc text column. A
|
||||
log written by either program therefore opens in the other with its QTCs
|
||||
intact, and a QTC row is written to the Cabrillo file as a `QTC:` record.
|
||||
|
||||
**Entering QTCs while operating is not there yet.** The rules, the scoring and
|
||||
the file formats are; the window for taking traffic down is next.
|
||||
**Ctrl+Z** opens the QTC window for the station being worked, which is the key
|
||||
N1MM uses. The window is laid out the same way: the header, ten lines of time,
|
||||
callsign and serial number, an Agn and a Cfm button beside each, and Clear,
|
||||
Close and Cancel underneath. Green is a line that will be saved, red one that is
|
||||
still empty, yellow one that cannot be read. Enter and Tab move forward, space
|
||||
moves within a line.
|
||||
|
||||
Receiving, the lines are typed as they arrive. Sending, they are filled from the
|
||||
log — the contacts not reported to anybody yet, oldest first, never the station
|
||||
being worked, and never more than the ten any station may have — and cannot be
|
||||
edited. On CW and SSB the direction is fixed by the rules; on RTTY traffic goes
|
||||
both ways and the window has a switch.
|
||||
|
||||
Nothing in the window transmits. On CW the operator sends from the entry window,
|
||||
on SSB by voice, and RTTY has no digital window yet, so Agn, Cfm and RX Ready
|
||||
move the cursor rather than putting anything on the air.
|
||||
|
||||
The scoring was checked against six real WAE logs, and agrees with N1MM on
|
||||
points and on weighted multipliers to the contact.
|
||||
|
||||
@@ -39,10 +39,15 @@ and passed over. Acting on them means holding N1MM's section lists and its
|
||||
rules about which sections are retired, and getting that wrong throws away good
|
||||
data. The file is read without them.
|
||||
|
||||
**Entering WAE QTC traffic.** The contest, the QTC rows, the scoring and the
|
||||
Cabrillo `QTC:` records are in, and a log written by N1MM reads back with its
|
||||
QTCs. What is missing is the window an operator takes traffic in: today a QTC
|
||||
can only reach the log by being read from a file N1MM wrote.
|
||||
**The QTC window does not transmit.** Ctrl+Z opens it and traffic goes into the
|
||||
log, but N1MM sends the QTC text itself: the lines on CW and RTTY, and recorded
|
||||
voice messages for QRV, Agn, Cfm and TU on SSB. Here the Agn, Cfm and RX Ready
|
||||
buttons only move the cursor. What is missing under it is the message templates
|
||||
for QTC traffic, a voice keyer, and the digital window.
|
||||
|
||||
**N1MM's QTC setup tab is not there.** Field spacing, the CW messages for Agn
|
||||
and TU, and the settings that skip the Cfm and QRV steps are all N1MM options
|
||||
this window does not have.
|
||||
|
||||
**Two WAE numbers do not agree with N1MM, both from 2020 and 2021 logs.** The
|
||||
disagreements are the country file of the day, not the rules: 4U1A counted as
|
||||
|
||||
BIN
om5m.s3db-shm
BIN
om5m.s3db-shm
Binary file not shown.
@@ -161,8 +161,14 @@ public sealed class AppSession : IDisposable
|
||||
}
|
||||
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||
Logging.Logged += (_, qso) =>
|
||||
{
|
||||
// a WAE QTC row is traffic about a station, not a station on the air
|
||||
if (contest.IsContact(qso))
|
||||
{
|
||||
Bandmap.Add(new Spot(qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||
}
|
||||
};
|
||||
Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
|
||||
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
||||
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
||||
|
||||
@@ -254,6 +254,10 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||
break;
|
||||
case Key.Z when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
_ = OpenQtcWindow();
|
||||
break;
|
||||
case Key.B when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
ToggleAlternatingCq();
|
||||
@@ -456,6 +460,50 @@ public sealed partial class EntryWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// WAE QTC traffic, on Ctrl+Z as in N1MM. The contact is logged first if it
|
||||
/// is still in the boxes: the traffic belongs to a station that has been
|
||||
/// worked.
|
||||
private async Task OpenQtcWindow()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QtcTraffic traffic = new(Logging);
|
||||
if (!traffic.IsWaeContest)
|
||||
{
|
||||
Status("QTC traffic is a WAE thing");
|
||||
return;
|
||||
}
|
||||
if (Logging.Entry.Call.Trim().Length > 0 && Logging.Entry.IsComplete)
|
||||
{
|
||||
OnEnter();
|
||||
}
|
||||
string typed = Logging.Entry.Call.Trim();
|
||||
Callsign station = Callsign.Parse(
|
||||
typed.Length > 0 ? typed : Logging.Log.Qsos.LastOrDefault()?.Call.Text ?? "");
|
||||
QtcDirection direction = traffic.DirectionFor(station, out string why);
|
||||
if (direction == QtcDirection.None)
|
||||
{
|
||||
Status(why);
|
||||
return;
|
||||
}
|
||||
if (direction is QtcDirection.Send && traffic.ToSend(station).Count == 0)
|
||||
{
|
||||
Status("nothing left to report to " + station.Text);
|
||||
return;
|
||||
}
|
||||
QtcWindow window = new(session, Logging, station, direction);
|
||||
bool saved = await window.ShowDialog<bool>(this);
|
||||
Logging.Wipe();
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
Refresh();
|
||||
Status(saved
|
||||
? $"QTC traffic with {station.Text} logged"
|
||||
: $"QTC traffic with {station.Text} left alone");
|
||||
}
|
||||
|
||||
/// Alternating CQ: CQ on this radio, then the other as each message ends.
|
||||
/// N1MM calls it dueling CQs and puts it on the same key.
|
||||
private void ToggleAlternatingCq()
|
||||
|
||||
29
src/Nonemm.App/Windows/QtcWindow.axaml
Normal file
29
src/Nonemm.App/Windows/QtcWindow.axaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Windows.QtcWindow"
|
||||
Title="QTC" SizeToContent="WidthAndHeight" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<StackPanel Margin="10" Spacing="4">
|
||||
<StackPanel Name="DirectionPanel" Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<RadioButton Name="ReceiveButton" Content="Receive" GroupName="direction" IsChecked="True" />
|
||||
<RadioButton Name="SendButton" Content="Send" GroupName="direction" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="QTC Header :" FontSize="11" Opacity="0.7" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBox Name="HeaderBox" Width="110" />
|
||||
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
|
||||
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
||||
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
||||
</StackPanel>
|
||||
<Grid Name="Lines" Margin="0,6,0,0" />
|
||||
<TextBlock Name="KeysText" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||
<TextBlock FontSize="11" Opacity="0.7" TextWrapping="Wrap" MaxWidth="420"
|
||||
Text="Enter, Tab move forward — space moves within a QTC line. Green = saved, red = not filled, yellow = format error." />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,6,0,0">
|
||||
<TextBlock Name="StatusText" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
|
||||
<Button Content="Clear" Click="OnClear" />
|
||||
<Button Name="CloseButton" Content="Close" Click="OnClose" IsDefault="True" />
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
372
src/Nonemm.App/Windows/QtcWindow.axaml.cs
Normal file
372
src/Nonemm.App/Windows/QtcWindow.axaml.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// Taking QTC traffic down, or reading it out. Laid out as N1MM lays it out: a
|
||||
/// header, ten lines of time, callsign and serial number, an Agn and a Cfm
|
||||
/// button beside each, and Clear, Close and Cancel underneath.
|
||||
///
|
||||
/// Receiving, the lines are typed as they arrive. Sending, they are filled from
|
||||
/// the log and cannot be edited — what is being reported is what was worked.
|
||||
/// Nothing here transmits: on CW the operator sends from the entry window, on
|
||||
/// SSB by voice, on RTTY from the digital window there is not yet.
|
||||
public sealed partial class QtcWindow : Window
|
||||
{
|
||||
private static readonly IBrush Saved = new SolidColorBrush(Color.FromRgb(0xD4, 0xF5, 0xD4));
|
||||
private static readonly IBrush Unsaved = new SolidColorBrush(Color.FromRgb(0xFF, 0xE4, 0xE1));
|
||||
private static readonly IBrush Malformed = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xC4));
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly RadioPosition position;
|
||||
private readonly QtcTraffic traffic;
|
||||
private readonly Callsign station;
|
||||
private bool isSending;
|
||||
private readonly List<Row> rows = [];
|
||||
private readonly List<WaeQtc> ready = [];
|
||||
|
||||
public QtcWindow(
|
||||
AppSession session,
|
||||
RadioPosition position,
|
||||
Callsign station,
|
||||
QtcDirection direction)
|
||||
{
|
||||
this.session = session;
|
||||
this.position = position;
|
||||
this.station = station;
|
||||
isSending = direction == QtcDirection.Send;
|
||||
traffic = new QtcTraffic(position);
|
||||
InitializeComponent();
|
||||
// on RTTY traffic goes both ways, so the operator says which this is
|
||||
DirectionPanel.IsVisible = direction == QtcDirection.Either;
|
||||
SendButton.IsChecked = isSending;
|
||||
ReceiveButton.IsChecked = !isSending;
|
||||
SendButton.IsCheckedChanged += (_, _) => SetDirection(SendButton.IsChecked == true);
|
||||
Reset();
|
||||
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
|
||||
// focus set before the window is on screen does not stick
|
||||
Opened += (_, _) => FirstBox().Focus();
|
||||
}
|
||||
|
||||
/// Where the cursor goes: the header when it has to be typed, the first
|
||||
/// line when it is already filled in.
|
||||
private TextBox FirstBox() => isSending ? rows[0].Time : HeaderBox;
|
||||
|
||||
private void SetDirection(bool sending)
|
||||
{
|
||||
if (sending == isSending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
isSending = sending;
|
||||
Reset();
|
||||
FirstBox().Focus();
|
||||
}
|
||||
|
||||
/// Everything that follows from which way the traffic goes.
|
||||
private void Reset()
|
||||
{
|
||||
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
|
||||
KeysText.Text = isSending
|
||||
? $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free"
|
||||
: "Type the header, then a line per QTC: time, callsign, serial number.";
|
||||
ReadyButton.IsVisible = !isSending;
|
||||
HeaderAgainButton.IsVisible = !isSending;
|
||||
HeaderCfmButton.IsVisible = !isSending;
|
||||
HeaderBox.IsReadOnly = isSending;
|
||||
BuildRows();
|
||||
Fill();
|
||||
}
|
||||
|
||||
private void BuildRows()
|
||||
{
|
||||
Lines.ColumnDefinitions.Clear();
|
||||
Lines.RowDefinitions.Clear();
|
||||
Lines.Children.Clear();
|
||||
rows.Clear();
|
||||
foreach (string width in new[] { "60", "120", "60", "Auto", "Auto" })
|
||||
{
|
||||
Lines.ColumnDefinitions.Add(new ColumnDefinition(
|
||||
width == "Auto" ? GridLength.Auto : new GridLength(double.Parse(width))));
|
||||
}
|
||||
for (int at = 0; at < WaeQtc.MostPerSeries; at++)
|
||||
{
|
||||
Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
TextBox time = Box(at, 0);
|
||||
TextBox call = Box(at, 1);
|
||||
TextBox number = Box(at, 2);
|
||||
Button again = new() { Content = $"Agn{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
||||
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
||||
int line = at;
|
||||
again.Click += (_, _) => OnLineAgain(line);
|
||||
confirm.Click += (_, _) => OnLineConfirm(line);
|
||||
Grid.SetRow(again, at);
|
||||
Grid.SetColumn(again, 3);
|
||||
Grid.SetRow(confirm, at);
|
||||
Grid.SetColumn(confirm, 4);
|
||||
Lines.Children.Add(again);
|
||||
Lines.Children.Add(confirm);
|
||||
rows.Add(new Row(time, call, number, again, confirm));
|
||||
}
|
||||
}
|
||||
|
||||
private TextBox Box(int row, int column)
|
||||
{
|
||||
TextBox box = new()
|
||||
{
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
Margin = new Avalonia.Thickness(2, 1, 2, 1),
|
||||
IsReadOnly = isSending,
|
||||
};
|
||||
box.TextChanged += (_, _) => Paint();
|
||||
Grid.SetRow(box, row);
|
||||
Grid.SetColumn(box, column);
|
||||
Lines.Children.Add(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
/// Sending, the header and the lines come from the log. Receiving, both are
|
||||
/// empty and the operator fills them in.
|
||||
private void Fill()
|
||||
{
|
||||
ready.Clear();
|
||||
if (!isSending)
|
||||
{
|
||||
HeaderBox.Text = "";
|
||||
foreach (Row row in rows)
|
||||
{
|
||||
row.SetEnabled(true);
|
||||
}
|
||||
Paint();
|
||||
return;
|
||||
}
|
||||
ready.AddRange(traffic.ToSend(station));
|
||||
// the contest is what makes it RTTY, not whatever the radio is on now
|
||||
bool rtty = position.Contest.Modes.Contains(ModeCategory.Digital);
|
||||
HeaderBox.Text = ready.Count == 0
|
||||
? ""
|
||||
: rtty
|
||||
? $"QTC {ready[0].SeriesText} - {station.Text}"
|
||||
: $"QTC {ready[0].SeriesText}";
|
||||
for (int at = 0; at < rows.Count; at++)
|
||||
{
|
||||
bool has = at < ready.Count;
|
||||
rows[at].Time.Text = has ? ready[at].TimeUtc : "";
|
||||
rows[at].Call.Text = has ? ready[at].Call.Text : "";
|
||||
rows[at].Number.Text = has ? $"{ready[at].Number:00}" : "";
|
||||
rows[at].SetEnabled(has);
|
||||
}
|
||||
if (ready.Count == 0)
|
||||
{
|
||||
StatusText.Text = "nothing left to report";
|
||||
}
|
||||
Paint();
|
||||
}
|
||||
|
||||
/// Green when the line is ready to save, yellow when it is filled in but
|
||||
/// cannot be read, red while it is still empty.
|
||||
private void Paint()
|
||||
{
|
||||
(int Series, int Count)? header = QtcTraffic.ReadHeader(HeaderBox.Text ?? "");
|
||||
HeaderBox.Background = header is null
|
||||
? (HeaderBox.Text ?? "").Trim().Length == 0 ? Unsaved : Malformed
|
||||
: Saved;
|
||||
int good = 0;
|
||||
for (int at = 0; at < rows.Count; at++)
|
||||
{
|
||||
Row row = rows[at];
|
||||
bool empty = row.IsEmpty;
|
||||
WaeQtc? line = empty || header is null ? null : ReadLine(at, header.Value);
|
||||
IBrush colour = line is not null ? Saved : empty ? Unsaved : Malformed;
|
||||
row.Paint(colour);
|
||||
if (line is not null)
|
||||
{
|
||||
good++;
|
||||
}
|
||||
}
|
||||
StatusText.Text = $"{good} of {header?.Count ?? 0}";
|
||||
}
|
||||
|
||||
private WaeQtc? ReadLine(int at, (int Series, int Count) header) =>
|
||||
QtcTraffic.Read(
|
||||
station,
|
||||
isSending,
|
||||
header.Series,
|
||||
header.Count,
|
||||
rows[at].Time.Text ?? "",
|
||||
rows[at].Call.Text ?? "",
|
||||
rows[at].Number.Text ?? "");
|
||||
|
||||
/// Enter and Tab move forward through the boxes; space moves within a line,
|
||||
/// which is how an operator walks a QTC while listening.
|
||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
e.Handled = true;
|
||||
Close(false);
|
||||
break;
|
||||
case Key.Enter when e.Source is TextBox:
|
||||
e.Handled = true;
|
||||
MoveOn();
|
||||
break;
|
||||
case Key.Space when e.Source is TextBox:
|
||||
e.Handled = true;
|
||||
MoveOn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveOn()
|
||||
{
|
||||
List<TextBox> order = [HeaderBox, .. rows.SelectMany(r => new[] { r.Time, r.Call, r.Number })];
|
||||
int at = order.FindIndex(b => b.IsFocused);
|
||||
if (at < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int next = at + 1; next < order.Count; next++)
|
||||
{
|
||||
if (order[next].IsEffectivelyEnabled)
|
||||
{
|
||||
order[next].Focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
CloseButton.Focus();
|
||||
}
|
||||
|
||||
/// N1MM's RX Ready tells the other station to go ahead; with no transmit
|
||||
/// path here it only puts the cursor where the first line will land.
|
||||
private void OnReady(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
|
||||
private void OnHeaderAgain(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
HeaderBox.Text = "";
|
||||
HeaderBox.Focus();
|
||||
}
|
||||
|
||||
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
|
||||
/// Ask for a line again: N1MM sends its Agn message and comes back to the
|
||||
/// line. Here it clears the line and puts the cursor in it.
|
||||
private void OnLineAgain(int at)
|
||||
{
|
||||
if (isSending)
|
||||
{
|
||||
rows[at].Time.Focus();
|
||||
return;
|
||||
}
|
||||
rows[at].Clear();
|
||||
rows[at].Time.Focus();
|
||||
Paint();
|
||||
}
|
||||
|
||||
private void OnLineConfirm(int at)
|
||||
{
|
||||
Control next = at + 1 < rows.Count ? rows[at + 1].Time : CloseButton;
|
||||
next.Focus();
|
||||
}
|
||||
|
||||
private void OnClear(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
foreach (Row row in rows)
|
||||
{
|
||||
row.Clear();
|
||||
}
|
||||
Fill();
|
||||
}
|
||||
|
||||
/// Close saves what is filled in, which is what N1MM's Close does. A line
|
||||
/// that cannot be read is left out rather than guessed at.
|
||||
private void OnClose(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (QtcTraffic.ReadHeader(HeaderBox.Text ?? "") is not { } header)
|
||||
{
|
||||
StatusText.Text = "the header is not a series";
|
||||
HeaderBox.Focus();
|
||||
return;
|
||||
}
|
||||
List<WaeQtc> lines = [];
|
||||
for (int at = 0; at < rows.Count; at++)
|
||||
{
|
||||
if (!rows[at].IsEmpty && ReadLine(at, header) is { } line)
|
||||
{
|
||||
lines.Add(isSending && at < ready.Count ? ready[at] with { Series = header.Series } : line);
|
||||
}
|
||||
}
|
||||
Save(lines);
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
|
||||
/// One row per line, a second apart, so they keep their order in the log.
|
||||
/// That is what N1MM does with them too.
|
||||
private void Save(IReadOnlyList<WaeQtc> lines)
|
||||
{
|
||||
DateTime at = DateTime.UtcNow;
|
||||
at = at.AddTicks(-(at.Ticks % TimeSpan.TicksPerSecond));
|
||||
for (int line = 0; line < lines.Count; line++)
|
||||
{
|
||||
Qso row = new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = at.AddSeconds(line),
|
||||
Call = station,
|
||||
Frequency = position.Frequency,
|
||||
Mode = position.Mode,
|
||||
RadioNumber = position.RadioNumber,
|
||||
ContestName = position.Contest.Name,
|
||||
ContestNumber = position.Instance.ContestNumber,
|
||||
Operator = position.Session.Operator.Length > 0
|
||||
? position.Session.Operator
|
||||
: position.Me.Callsign,
|
||||
IsRunQso = position.IsRunning,
|
||||
};
|
||||
position.Session.Add(lines[line].ApplyTo(CountryFields.Apply(row, session.Countries)));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Row(
|
||||
TextBox Time,
|
||||
TextBox Call,
|
||||
TextBox Number,
|
||||
Button Again,
|
||||
Button Confirm)
|
||||
{
|
||||
public bool IsEmpty =>
|
||||
(Time.Text ?? "").Trim().Length == 0
|
||||
&& (Call.Text ?? "").Trim().Length == 0
|
||||
&& (Number.Text ?? "").Trim().Length == 0;
|
||||
|
||||
public void Paint(IBrush colour)
|
||||
{
|
||||
Time.Background = colour;
|
||||
Call.Background = colour;
|
||||
Number.Background = colour;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Time.Text = "";
|
||||
Call.Text = "";
|
||||
Number.Text = "";
|
||||
}
|
||||
|
||||
public void SetEnabled(bool enabled)
|
||||
{
|
||||
Time.IsEnabled = enabled;
|
||||
Call.IsEnabled = enabled;
|
||||
Number.IsEnabled = enabled;
|
||||
Again.IsEnabled = enabled;
|
||||
Confirm.IsEnabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,18 @@ public sealed record WaeQtc(
|
||||
int CountInSeries,
|
||||
string TimeUtc,
|
||||
Callsign Call,
|
||||
int Number)
|
||||
int Number,
|
||||
int OwnNumber = 0)
|
||||
{
|
||||
public const string Contact = "QSO";
|
||||
public const string Sent = "SQTC";
|
||||
public const string Received = "RQTC";
|
||||
|
||||
/// `Number` is the serial that station sent us in the contact being
|
||||
/// reported, and it is what goes out on the air. `OwnNumber` is the serial
|
||||
/// we sent them, kept only so the same contact is not reported twice;
|
||||
/// N1MM keeps it in the `NR` column for the same reason.
|
||||
///
|
||||
/// Ten lines to a series, and ten to any one station for the whole contest.
|
||||
public const int MostPerSeries = 10;
|
||||
|
||||
@@ -49,7 +55,8 @@ public sealed record WaeQtc(
|
||||
count,
|
||||
qso.SentReport,
|
||||
Callsign.Parse(qso.ReceivedReport),
|
||||
qso.SentNumber);
|
||||
qso.SentNumber,
|
||||
qso.ReceivedNumber);
|
||||
}
|
||||
|
||||
/// Fills this QTC into a log row. The caller supplies the row so the
|
||||
@@ -62,7 +69,7 @@ public sealed record WaeQtc(
|
||||
SentReport = TimeUtc,
|
||||
ReceivedReport = Call.Text,
|
||||
SentNumber = Number,
|
||||
ReceivedNumber = 0,
|
||||
ReceivedNumber = OwnNumber,
|
||||
};
|
||||
|
||||
private static (int Series, int Count) ParseSeries(string text)
|
||||
|
||||
184
src/Nonemm.Session/QtcTraffic.cs
Normal file
184
src/Nonemm.Session/QtcTraffic.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Which way QTC traffic goes with a station, if it goes at all.
|
||||
public enum QtcDirection
|
||||
{
|
||||
None,
|
||||
Send,
|
||||
Receive,
|
||||
|
||||
/// RTTY, where traffic goes both ways and the operator picks.
|
||||
Either,
|
||||
}
|
||||
|
||||
/// The WAE QTC rules that do not need a window: who may exchange traffic with
|
||||
/// whom, which contacts are still worth reporting, how the series are numbered,
|
||||
/// and whether a line an operator has typed reads as a QTC.
|
||||
///
|
||||
/// On CW and SSB, traffic goes one way only: the station outside Europe reports
|
||||
/// its contacts to the European station. On RTTY it goes both ways, and the
|
||||
/// only rule left is that the two stations are on different continents.
|
||||
public sealed class QtcTraffic
|
||||
{
|
||||
private readonly RadioPosition position;
|
||||
|
||||
public QtcTraffic(RadioPosition position) => this.position = position;
|
||||
|
||||
public bool IsWaeContest => position.Contest is Wae;
|
||||
|
||||
/// Which way traffic would go with this station. `reason` says why not when
|
||||
/// the answer is `None`, in the words the status line shows.
|
||||
public QtcDirection DirectionFor(Callsign other, out string reason)
|
||||
{
|
||||
reason = "";
|
||||
if (position.Contest is not Wae contest)
|
||||
{
|
||||
reason = "QTC traffic is a WAE thing";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
if (other.Text.Length < 3)
|
||||
{
|
||||
reason = "work a station first";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
if (Remaining(other) <= 0)
|
||||
{
|
||||
reason = $"{other.Text} has had its ten QTCs";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
string theirs = ContinentOf(other);
|
||||
bool sameContinent = theirs.Length > 0 && theirs == position.Me.Continent;
|
||||
if (contest.Modes.Contains(ModeCategory.Digital))
|
||||
{
|
||||
if (sameContinent)
|
||||
{
|
||||
reason = $"{other.Text} is on this continent";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
return QtcDirection.Either;
|
||||
}
|
||||
if (position.Me.Continent == "EU")
|
||||
{
|
||||
if (theirs == "EU")
|
||||
{
|
||||
reason = $"{other.Text} is in Europe";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
return QtcDirection.Receive;
|
||||
}
|
||||
if (theirs != "EU")
|
||||
{
|
||||
reason = $"{other.Text} is not in Europe";
|
||||
return QtcDirection.None;
|
||||
}
|
||||
return QtcDirection.Send;
|
||||
}
|
||||
|
||||
/// How many QTC lines have already gone either way with this station. Ten
|
||||
/// is the lot, for the whole contest and whatever the band.
|
||||
public int ExchangedWith(Callsign other) =>
|
||||
position.Log.Qsos.Count(q =>
|
||||
WaeQtc.IsQtc(q) && q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int Remaining(Callsign other) =>
|
||||
Math.Max(0, WaeQtc.MostPerStation - ExchangedWith(other));
|
||||
|
||||
/// Series are numbered across the whole contest, not per station: the
|
||||
/// hundredth series a station sends is `100/10` whoever gets it.
|
||||
public int NextSeries()
|
||||
{
|
||||
int last = position.Log.Qsos
|
||||
.Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(q => WaeQtc.From(q)?.Series ?? 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return last + 1;
|
||||
}
|
||||
|
||||
/// The contacts to report to this station: the ones not reported to anybody
|
||||
/// yet, oldest first, and never the station itself. Ten at a time, fewer
|
||||
/// when it has already had some.
|
||||
public IReadOnlyList<WaeQtc> ToSend(Callsign other)
|
||||
{
|
||||
int room = Math.Min(WaeQtc.MostPerSeries, Remaining(other));
|
||||
if (room <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
HashSet<string> reported = position.Log.Qsos
|
||||
.Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
List<Qso> worth = position.Log.Qsos
|
||||
.Where(q => !WaeQtc.IsQtc(q))
|
||||
.Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber)))
|
||||
.OrderBy(q => q.TimestampUtc)
|
||||
.Take(room)
|
||||
.ToList();
|
||||
int series = NextSeries();
|
||||
return [.. worth.Select(q => new WaeQtc(
|
||||
other,
|
||||
IsSent: true,
|
||||
series,
|
||||
worth.Count,
|
||||
q.TimestampUtc.ToString("HHmm"),
|
||||
q.Call,
|
||||
q.ReceivedNumber,
|
||||
q.SentNumber))];
|
||||
}
|
||||
|
||||
/// What the operator has typed, read as a QTC. Null when a field is empty
|
||||
/// or malformed, which is what the window paints yellow.
|
||||
public static WaeQtc? Read(
|
||||
Callsign station,
|
||||
bool isSent,
|
||||
int series,
|
||||
int countInSeries,
|
||||
string time,
|
||||
string call,
|
||||
string number)
|
||||
{
|
||||
string trimmedTime = time.Trim();
|
||||
string trimmedCall = call.Trim().ToUpperInvariant();
|
||||
if (trimmedTime.Length != 4
|
||||
|| !trimmedTime.All(char.IsAsciiDigit)
|
||||
|| !IsClockTime(trimmedTime)
|
||||
|| trimmedCall.Length < 3
|
||||
|| !int.TryParse(number.Trim(), out int parsed)
|
||||
|| parsed <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new WaeQtc(
|
||||
station, isSent, series, countInSeries, trimmedTime, Callsign.Parse(trimmedCall), parsed);
|
||||
}
|
||||
|
||||
/// The header a station sends in front of a series: a series number, a
|
||||
/// separator and how many lines follow. Anything between the two numbers
|
||||
/// counts as the separator, because it is copied off the air.
|
||||
public static (int Series, int Count)? ReadHeader(string text)
|
||||
{
|
||||
string[] parts = text.Trim().ToUpperInvariant().Replace("QTC", "").Trim()
|
||||
.Split(['/', '-', ' ', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2
|
||||
&& int.TryParse(parts[0], out int series)
|
||||
&& int.TryParse(parts[1], out int count)
|
||||
&& series > 0
|
||||
&& count is > 0 and <= WaeQtc.MostPerSeries
|
||||
? (series, count)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsClockTime(string text) =>
|
||||
int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60;
|
||||
|
||||
private static string ReportedKey(string call, int number) => $"{call}|{number}";
|
||||
|
||||
private string ContinentOf(Callsign call) =>
|
||||
position.Session.Countries?.Find(call)?.Continent ?? "";
|
||||
}
|
||||
210
tests/Nonemm.Session.Tests/QtcTrafficTests.cs
Normal file
210
tests/Nonemm.Session.Tests/QtcTrafficTests.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.Session.Tests;
|
||||
|
||||
public class QtcTrafficTests
|
||||
{
|
||||
private const string Countries = """
|
||||
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
||||
DL,DK,DJ;
|
||||
Slovakia: 15: 28: EU: 48.70: -19.50: -1.0: OM:
|
||||
OM;
|
||||
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
|
||||
K,W,N,AA,NE;
|
||||
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
|
||||
JA,JH,JR;
|
||||
""";
|
||||
|
||||
private static readonly StationInfo Slovakia = new()
|
||||
{
|
||||
Callsign = "OM5M",
|
||||
CqZone = 15,
|
||||
ItuZone = 28,
|
||||
Continent = "EU",
|
||||
CountryPrefix = "OM",
|
||||
};
|
||||
|
||||
private static readonly StationInfo UnitedStates = new()
|
||||
{
|
||||
Callsign = "NE1C",
|
||||
CqZone = 5,
|
||||
ItuZone = 8,
|
||||
Continent = "NA",
|
||||
CountryPrefix = "K",
|
||||
};
|
||||
|
||||
private static (RadioPosition Position, QtcTraffic Traffic) Station(
|
||||
ModeCategory mode,
|
||||
StationInfo? me = null)
|
||||
{
|
||||
FakeLogStore store = new();
|
||||
ContestInstance instance = store.AddContest(new ContestInstance
|
||||
{
|
||||
ContestNumber = 0,
|
||||
ContestName = "WAERTTY",
|
||||
});
|
||||
ContestSession session = new(
|
||||
store, new Wae(mode), instance, me ?? Slovakia, CountryFile.Parse(Countries));
|
||||
RadioPosition position = new(session);
|
||||
return (position, new QtcTraffic(position));
|
||||
}
|
||||
|
||||
private static Qso Contact(
|
||||
RadioPosition position,
|
||||
string call,
|
||||
int receivedNumber,
|
||||
int sentNumber,
|
||||
int minute) =>
|
||||
position.Session.Add(new Qso
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = new DateTime(2026, 11, 14, 13, minute, 0, DateTimeKind.Utc),
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency.FromKilohertz(14_080),
|
||||
Mode = Modes.Rtty,
|
||||
ContestName = "WAERTTY",
|
||||
ReceivedNumber = receivedNumber,
|
||||
SentNumber = sentNumber,
|
||||
Exchange1 = WaeQtc.Contact,
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void OnCwEuropeReceivesAndTheRestOfTheWorldSends()
|
||||
{
|
||||
(_, QtcTraffic european) = Station(ModeCategory.Cw);
|
||||
(_, QtcTraffic american) = Station(ModeCategory.Cw, UnitedStates);
|
||||
|
||||
Assert.Equal(QtcDirection.Receive, european.DirectionFor(Callsign.Parse("NE1C"), out _));
|
||||
Assert.Equal(QtcDirection.Send, american.DirectionFor(Callsign.Parse("DL9XYZ"), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCwThereIsNoTrafficWithinEurope()
|
||||
{
|
||||
(_, QtcTraffic traffic) = Station(ModeCategory.Cw);
|
||||
|
||||
Assert.Equal(QtcDirection.None, traffic.DirectionFor(Callsign.Parse("DL9XYZ"), out string why));
|
||||
Assert.Contains("Europe", why);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRttyTrafficGoesBothWaysBetweenContinents()
|
||||
{
|
||||
(_, QtcTraffic traffic) = Station(ModeCategory.Digital);
|
||||
|
||||
Assert.Equal(QtcDirection.Either, traffic.DirectionFor(Callsign.Parse("JA1XYZ"), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRttyThereIsNoTrafficWithinOneContinent()
|
||||
{
|
||||
(_, QtcTraffic traffic) = Station(ModeCategory.Digital);
|
||||
|
||||
Assert.Equal(QtcDirection.None, traffic.DirectionFor(Callsign.Parse("DL9XYZ"), out string why));
|
||||
Assert.Contains("continent", why);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenLinesIsAllAnyStationGets()
|
||||
{
|
||||
(RadioPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
|
||||
for (int at = 1; at <= 10; at++)
|
||||
{
|
||||
position.Session.Add(new WaeQtc(
|
||||
Callsign.Parse("JA1XYZ"), IsSent: true, 1, 10, "1300", Callsign.Parse("DL9XYZ"), at)
|
||||
.ApplyTo(Contact(position, "K1ABC", at, at, at)));
|
||||
}
|
||||
|
||||
Assert.Equal(0, traffic.Remaining(Callsign.Parse("JA1XYZ")));
|
||||
Assert.Equal(QtcDirection.None, traffic.DirectionFor(Callsign.Parse("JA1XYZ"), out string why));
|
||||
Assert.Contains("ten QTCs", why);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhatIsSentIsTheContactsNotReportedYetOldestFirst()
|
||||
{
|
||||
(RadioPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
|
||||
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
|
||||
Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
|
||||
Contact(position, "JA1XYZ", receivedNumber: 7, sentNumber: 3, minute: 48);
|
||||
|
||||
IReadOnlyList<WaeQtc> lines = traffic.ToSend(Callsign.Parse("JA1XYZ"));
|
||||
|
||||
Assert.Equal(["DL1AAA", "DL2BBB"], lines.Select(l => l.Call.Text).ToList());
|
||||
Assert.Equal(["1346", "1347"], lines.Select(l => l.TimeUtc).ToList());
|
||||
Assert.Equal([16, 40], lines.Select(l => l.Number).ToList());
|
||||
Assert.All(lines, line => Assert.Equal("1/2", line.SeriesText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AContactAlreadyReportedIsNotReportedAgain()
|
||||
{
|
||||
(RadioPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
|
||||
Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
|
||||
Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
|
||||
WaeQtc first = traffic.ToSend(Callsign.Parse("JA1XYZ"))[0];
|
||||
position.Session.Add(first.ApplyTo(Contact(position, "JA1XYZ", 1, 3, 50)));
|
||||
|
||||
Assert.Equal(["DL2BBB"], traffic.ToSend(Callsign.Parse("JA1XYZ")).Select(l => l.Call.Text).ToList());
|
||||
}
|
||||
|
||||
/// Series are numbered across the contest, so the next one carries on from
|
||||
/// the last series sent to anybody.
|
||||
[Fact]
|
||||
public void SeriesAreNumberedAcrossTheContest()
|
||||
{
|
||||
(RadioPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
|
||||
Assert.Equal(1, traffic.NextSeries());
|
||||
|
||||
position.Session.Add(new WaeQtc(
|
||||
Callsign.Parse("JA1XYZ"), IsSent: true, 86, 10, "1300", Callsign.Parse("DL9XYZ"), 5)
|
||||
.ApplyTo(Contact(position, "JA1XYZ", 1, 1, 55)));
|
||||
|
||||
Assert.Equal(87, traffic.NextSeries());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1346", "DL2UH", "40")]
|
||||
[InlineData("0001", "2E0GYI", "1")]
|
||||
public void AWellFormedLineReadsAsAQtc(string time, string call, string number) =>
|
||||
Assert.NotNull(QtcTraffic.Read(
|
||||
Callsign.Parse("NE1C"), isSent: false, 87, 10, time, call, number));
|
||||
|
||||
[Theory]
|
||||
[InlineData("134", "DL2UH", "40")]
|
||||
[InlineData("2401", "DL2UH", "40")]
|
||||
[InlineData("1360", "DL2UH", "40")]
|
||||
[InlineData("1346", "DL", "40")]
|
||||
[InlineData("1346", "DL2UH", "")]
|
||||
[InlineData("1346", "DL2UH", "abc")]
|
||||
public void ALineThatIsNotAQtcReadsAsNothing(string time, string call, string number) =>
|
||||
Assert.Null(QtcTraffic.Read(
|
||||
Callsign.Parse("NE1C"), isSent: false, 87, 10, time, call, number));
|
||||
|
||||
/// The header is copied off the air, so whatever separates the two numbers
|
||||
/// is taken as the separator.
|
||||
[Theory]
|
||||
[InlineData("87/10")]
|
||||
[InlineData("87 10")]
|
||||
[InlineData("87-10")]
|
||||
[InlineData("QTC 87/10")]
|
||||
public void AHeaderIsASeriesAndACount(string text)
|
||||
{
|
||||
(int series, int count) = Assert.IsType<(int, int)>(QtcTraffic.ReadHeader(text));
|
||||
|
||||
Assert.Equal(87, series);
|
||||
Assert.Equal(10, count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("87")]
|
||||
[InlineData("87/11")]
|
||||
[InlineData("abc/10")]
|
||||
public void AHeaderThatIsNotOneReadsAsNothing(string text) =>
|
||||
Assert.Null(QtcTraffic.ReadHeader(text));
|
||||
}
|
||||
Reference in New Issue
Block a user