Files
Nonemm/src/Nonemm.Session/QtcTraffic.cs
T
erikbrockoandClaude Opus 5 9b3e10d127 Take a QTC header that is only a series number as ten lines
An operator copying a header off the air often types the series number and
nothing else. That read as a header the program could not parse, so the box
went yellow and Close refused to log the series.

N1MM fills the count in: "If he only types the batch number without the
forward slash and count, the program will assume the count is 10."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
2026-09-10 19:30:47 +00:00

278 lines
11 KiB
C#

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 QTC tally for one station, as N1MM writes it beside the callsign:
/// how many lines have already gone with it, how many more may go, and how
/// many logged contacts are left to report to anybody.
public sealed record QtcCounts(QtcDirection Role, int Exchanged, int LeftWith, int Available)
{
/// N1MM's wording, one line per role. Europe on CW and SSB only takes
/// traffic down, so there is no pile of contacts to report and the line
/// leaves that number out.
public string Text => Role switch
{
QtcDirection.Receive => $"QTCs: {Exchanged} rcvd/{LeftWith} left to exch.",
QtcDirection.Send =>
$"QTCs: {Exchanged} sent/{LeftWith} left to exch/{Available} total QTCs avail",
QtcDirection.Either =>
$"QTCs: {Exchanged} exch/{LeftWith} left to exch/{Available} total QTCs avail",
_ => "",
};
/// N1MM puts this in the status bar as well when a station has had its ten.
public bool IsFull => Exchanged >= WaeQtc.MostPerStation;
}
/// 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 OperatingPosition position;
public QtcTraffic(OperatingPosition 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));
/// Which way traffic goes here whoever is worked: Europe takes it down on
/// CW and SSB, the rest of the world reads it out, and on RTTY it goes
/// both ways. `DirectionFor` answers the same question about one station;
/// this one ignores the station, so the counts still read out when the
/// callsign box is empty or holds a station with nothing left.
public QtcDirection Role
{
get
{
if (position.Contest is not Wae contest)
{
return QtcDirection.None;
}
if (contest.Modes.Contains(ModeCategory.Digital))
{
return QtcDirection.Either;
}
return position.Me.Continent == "EU" ? QtcDirection.Receive : QtcDirection.Send;
}
}
/// How many logged contacts are still worth reporting to this station: the
/// whole pile rather than one series of ten. This is N1MM's
/// `QTCRecordsetCount`, which is zero for a European station on CW and SSB
/// because it only takes traffic down.
public int Available(Callsign other) =>
Role == QtcDirection.Receive ? 0 : Reportable(other).Count();
/// The three numbers the entry window shows. `LeftWith` is what may still
/// go with this station: what is free of its ten, and no more than the
/// contacts there are to report when we are the one reporting.
public QtcCounts CountsFor(Callsign other)
{
int exchanged = ExchangedWith(other);
int free = Math.Max(0, WaeQtc.MostPerStation - exchanged);
int available = Available(other);
int left = Role == QtcDirection.Send ? Math.Min(free, available) : free;
return new QtcCounts(Role, exchanged, left, available);
}
/// 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 unless
/// the operator asks for a shorter series, and fewer when the station has
/// already had some. A contact marked as a dupe is not offered, which is
/// what N1MM's `QTCRecordsetCount` leaves out.
public IReadOnlyList<WaeQtc> ToSend(Callsign other, int mostPerSeries = WaeQtc.MostPerSeries)
{
int room = Math.Min(Math.Clamp(mostPerSeries, 1, WaeQtc.MostPerSeries), Remaining(other));
if (room <= 0)
{
return [];
}
List<Qso> worth = [.. Reportable(other).OrderBy(q => q.TimestampUtc).Take(room)];
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. A series
/// number on its own means ten lines, which is what N1MM assumes: "If he
/// only types the batch number without the forward slash and count, the
/// program will assume the count is 10."
public static (int Series, int Count)? ReadHeader(string text)
{
string[] parts = text.Trim().ToUpperInvariant().Replace("QTC", "").Trim()
.Split(['/', '-', ' ', '\\'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || !int.TryParse(parts[0], out int series) || series <= 0)
{
return null;
}
if (parts.Length == 1)
{
return (series, WaeQtc.MostPerSeries);
}
return int.TryParse(parts[1], out int count) && count is > 0 and <= WaeQtc.MostPerSeries
? (series, count)
: null;
}
/// A time typed short is filled in from the line above, which is what N1MM
/// does when the cursor leaves the box: `52` under `1148` reads as `1152`.
/// As many characters are taken off the front of the line above as the
/// typed part is missing. A colon is dropped whatever the length.
public static string FillTime(string typed, string above)
{
string text = typed.Trim().Replace(":", "");
string previous = above.Trim().Replace(":", "");
if (text.Length is 0 or >= 4)
{
return text;
}
int missing = 4 - text.Length;
return (previous.Length >= missing ? previous[..missing] : previous) + text;
}
private static bool IsClockTime(string text) =>
int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60;
/// The contacts not reported to anybody yet, without the dupes and without
/// the station in front of us. The caller puts them in order. What N1MM's
/// `QTCRecordsetCount` leaves out is the dupes.
private IEnumerable<Qso> Reportable(Callsign other)
{
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);
return position.Log.Qsos
.Where(q => !WaeQtc.IsQtc(q))
.Where(q => q.ContactType.Length == 0)
.Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase))
.Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber)));
}
private static string ReportedKey(string call, int number) => $"{call}|{number}";
private string ContinentOf(Callsign call) =>
position.Session.Countries?.Find(call)?.Continent ?? "";
}