Lay out a Cabrillo line the way a .udc file asks for it

`CabrilloString` names a field and a column width in turn. The line it makes
follows N1MM: the operator's callsign in the first 13 columns, then every
column padded to its width with one space after it, and a value longer than its
column cut rather than pushed sideways. The cut is new for every contest, not
just the user-defined ones — the columns are the sponsor's, not ours.

`CabrilloVersion` is read too, so a contest whose sponsor still asks for 2.0
gets it in the header.

A contest that lays out its own line can put the transmitter column where it
wants it, or leave it out, so the writer no longer adds one at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 10:38:39 +00:00
parent 5ab97251e1
commit cf5bdd6082
7 changed files with 187 additions and 18 deletions

View File

@@ -109,9 +109,17 @@ with the CW and SSB running of the contest.
which takes the value from the call history file. `BonusPoints2`, which reads
the bonus callsigns from a file. `MultiplierBands`, `MultWindowType` and
`QsoErrorString`, which are about the windows rather than the score.
`CabrilloString`, `CabrilloFormat` and `GenericPrintString`, so a contest whose
Cabrillo line is not callsign, report and one exchange value comes out in the
default shape. The session, off-time and band-change settings
`GenericPrintString`, which is the layout of a printed log rather than a
Cabrillo one.
`CabrilloString` and `CabrilloVersion` are read: a contest whose sponsor asks
for its own columns gets them, in N1MM's shape — the operator's callsign in the
first 13 columns, then every column padded to the width the file gives with one
space after it, and a value longer than its column cut rather than pushed. The
numbered `CabrilloFormat` layouts are not read: 2, 3, 4 and 5 name the NAQP,
NA Sprint, Sweepstakes and RFC lines, and a file asking for one of those gets
the default line instead. `CabrilloFormat = 0` means the sponsor takes no
Cabrillo log at all, and nothing here stops the operator writing one. The session, off-time and band-change settings
(`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family,
`DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged
as one long session.

View File

@@ -9,4 +9,10 @@ public sealed record CabrilloField(string Value, int Width);
/// goes: most put it first, Sweepstakes puts it last.
public sealed record CabrilloExchange(
IReadOnlyList<CabrilloField> Sent,
IReadOnlyList<CabrilloField> Received);
IReadOnlyList<CabrilloField> Received)
{
/// Whether the writer adds the transmitter column at the end of the line.
/// A user-defined contest that lays out its own line can put that column
/// where it wants it, or leave it out.
public bool AddsTransmitter { get; init; } = true;
}

View File

@@ -15,6 +15,10 @@ public interface Contest
/// The name the sponsor's Cabrillo header asks for.
string CabrilloName { get; }
/// The Cabrillo version the sponsor accepts. Sponsors have taken 3.0 for
/// years; a few user-defined contests still ask for 2.0.
string CabrilloVersion => "3.0";
/// The boxes the entry window shows. ARRL DX and others ask for a
/// different exchange depending on where the operator is.
IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me);

View File

@@ -0,0 +1,80 @@
using Nonemm.Core;
namespace Nonemm.Contests.Udc;
/// The `CabrilloString` setting: a field name and a column width in turn,
/// which together say what one QSO line holds after the date and time. N1MM
/// puts the operator's own callsign in the first 13 columns before anything
/// the setting names.
///
/// A name this does not know contributes an empty column of the width given,
/// so the columns after it stay where the sponsor expects them.
public sealed class UdcCabrillo
{
private readonly List<(string Field, int Width)> columns = [];
public UdcCabrillo(string setting)
{
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
for (int at = 0; at + 1 < parts.Length; at += 2)
{
if (int.TryParse(parts[at + 1].Trim(), out int width))
{
columns.Add((parts[at].Trim(), width));
}
}
}
public bool IsDefined => columns.Count > 0;
/// True when the contact's transmitter number is one of the columns, so the
/// writer does not add it a second time.
public bool HasTransmitter =>
columns.Any(c => c.Field.Equals("Run1Run2", StringComparison.OrdinalIgnoreCase));
public IReadOnlyList<CabrilloField> Fields(Qso qso, StationInfo me, ContestEntry entry) =>
[
new CabrilloField(me.Callsign, 13),
.. columns.Select(c => new CabrilloField(Value(c.Field, qso, entry), c.Width)),
];
private static string Value(string field, Qso qso, ContestEntry entry) =>
field.ToUpperInvariant() switch
{
"SNT" => qso.SentReport,
"RCV" => qso.ReceivedReport,
"SENTNR" => $"{qso.SentNumber:000}",
"RCVNR" => $"{qso.ReceivedNumber:000}",
"CALLSIGN" => qso.Call.Text,
"NAME" => qso.Name,
"COMMENT" => qso.Comment,
"EXCHANGE1" => qso.Exchange1,
"SECT" => qso.Section,
"MISC" or "MISCTEXT" => qso.MiscText,
"GRIDSQUARE" => qso.GridSquare,
"POINTS" => qso.Points.ToString(),
"MULTIPLIER1" => Flag(qso.IsMultiplier1),
"MULTIPLIER2" => Flag(qso.IsMultiplier2),
"MULTIPLIER3" => Flag(qso.IsMultiplier3),
"SENTEXCH" => entry.SentExchange,
"SENTEXCHPART1" => Part(entry.SentExchange, 0),
"SENTEXCHPART2" => Part(entry.SentExchange, 1),
"SENTEXCHPART3" => Part(entry.SentExchange, 2),
"FREQ" => qso.Frequency.Kilohertz.ToString("0"),
"MODE" => qso.Mode.CabrilloCode,
"TIMESTAMP" => qso.TimestampUtc.ToString("HHmm"),
"RUN1RUN2" => qso.RadioNumber > 1 ? "1" : "0",
"CONTACTNETWORKEDCOMPNR" => qso.NetworkedComputerNumber.ToString(),
_ => "",
};
private static string Flag(bool set) => set ? "1" : "0";
/// The sent exchange in parts, as the operator typed it: `599 14 BRA` is
/// three of them.
private static string Part(string sentExchange, int index)
{
string[] parts = sentExchange.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length > index ? parts[index] : "";
}
}

View File

@@ -14,6 +14,7 @@ public sealed class UserDefinedContest : Contest
private readonly IReadOnlyList<UdcMultiplier> multipliers;
private readonly IReadOnlyList<ExchangeField> exchangeFields;
private readonly IReadOnlyList<string> workableStations;
private readonly UdcCabrillo cabrillo;
public UserDefinedContest(UdcFile file)
{
@@ -24,6 +25,7 @@ public sealed class UserDefinedContest : Contest
multipliers = ReadMultipliers(file);
exchangeFields = ReadExchangeFields(file);
workableStations = file.List("IsWorkable");
cabrillo = new UdcCabrillo(file.Text("CabrilloString"));
}
public static UserDefinedContest Load(string path) =>
@@ -35,6 +37,8 @@ public sealed class UserDefinedContest : Contest
public string CabrilloName => file.Text("CabrilloName", Name);
public string CabrilloVersion => file.Text("CabrilloVersion", "3.0");
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) => exchangeFields;
public IReadOnlyList<string> MultiplierNames =>
@@ -115,18 +119,26 @@ public sealed class UserDefinedContest : Contest
return (int)Math.Round(total * pointsMultiplier.ForEntry(entry));
}
/// `CabrilloString` lays out the whole line, which is how a contest whose
/// sponsor asks for its own columns is written. Without it the line is the
/// usual one: each station, its report and its exchange.
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
new(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
new CabrilloField(SentExchangePart(qso), 6),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField(ReceivedExchangePart(qso), 6),
]);
cabrillo.IsDefined
? new CabrilloExchange(cabrillo.Fields(qso, me, entry), [])
{
AddsTransmitter = cabrillo.HasTransmitter,
}
: new CabrilloExchange(
[
new CabrilloField(me.Callsign, 13),
new CabrilloField(qso.SentReport, 3),
new CabrilloField(SentExchangePart(qso), 6),
],
[
new CabrilloField(qso.Call.Text, 13),
new CabrilloField(qso.ReceivedReport, 3),
new CabrilloField(ReceivedExchangePart(qso), 6),
]);
/// `IsWorkable` names the stations the contest counts: a continent, my own
/// country, everything but my own country, or a list of country prefixes. A

View File

@@ -23,7 +23,7 @@ public sealed class CabrilloWriter
public string Write(CabrilloHeader header, IEnumerable<Qso> qsos)
{
StringBuilder text = new();
text.Append("START-OF-LOG: 3.0\r\n");
text.Append("START-OF-LOG: ").Append(contest.CabrilloVersion).Append("\r\n");
Line(text, "CONTEST", header.Contest);
Line(text, "CALLSIGN", header.Callsign);
Line(text, "CATEGORY-OPERATOR", header.OperatorCategory);
@@ -72,18 +72,23 @@ public sealed class CabrilloWriter
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
Append(line, exchange.Sent);
Append(line, exchange.Received);
if (contest.IsContact(qso))
if (contest.IsContact(qso) && exchange.AddsTransmitter)
{
line.Append(qso.RadioNumber > 1 ? '1' : '0');
}
return line.ToString();
}
/// Every column is the width the sponsor asks for and one space, and a
/// value longer than its column is cut, which is what N1MM writes.
private static void Append(StringBuilder line, IReadOnlyList<CabrilloField> fields)
{
foreach (CabrilloField field in fields)
{
line.Append(field.Value.PadRight(field.Width)).Append(' ');
string value = field.Value.Length > field.Width
? field.Value[..field.Width]
: field.Value.PadRight(field.Width);
line.Append(value).Append(' ');
}
}

View File

@@ -1,4 +1,6 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Contests.Udc;
using Nonemm.Core;
using Nonemm.Formats.Cabrillo;
@@ -80,4 +82,56 @@ public class CabrilloWriterTests
[Contact() with { IsClaimed = false }]);
Assert.DoesNotContain("QSO:", log);
}
/// The SA 10 file, whose sponsor asks for its own columns: the operator's
/// call in the first 13, then every column padded to the width the file
/// gives, with one space after it.
[Fact]
public void AUserDefinedContestCanLayOutItsOwnLine()
{
UserDefinedContest contest = new(UdcFile.Parse("""
[Contest]
Name=SA10_DX
CabrilloName=SA10-DX
CabrilloFormat=99
CabrilloString=SNT, 4, SentExch, 4, CallSign, 13, RCV, 4, Exchange1, 4
"""));
CabrilloWriter writer = new(contest, Me, new ContestEntry { SentExchange = "14" });
string line = writer.QsoLine(Contact() with { Exchange1 = "25" });
Assert.Equal(
"QSO: 14025 CW 2026-05-30 1234 DL1ABC 599 14 JA1XYZ 599 25 ",
line);
}
/// A value longer than its column is cut rather than pushing the columns
/// after it out of line, which is what N1MM writes.
[Fact]
public void AValueTooLongForItsColumnIsCut()
{
UserDefinedContest contest = new(UdcFile.Parse("""
[Contest]
Name=TIGHT
CabrilloString=Exchange1, 3
"""));
CabrilloWriter writer = new(contest, Me);
Assert.EndsWith("ABC ", writer.QsoLine(Contact() with { Exchange1 = "ABCDEF" }));
}
[Fact]
public void TheCabrilloVersionComesFromTheContest()
{
UserDefinedContest contest = new(UdcFile.Parse("""
[Contest]
Name=OLD
CabrilloVersion=2.0
"""));
CabrilloWriter writer = new(contest, Me);
Assert.StartsWith(
"START-OF-LOG: 2.0",
writer.Write(new CabrilloHeader { Contest = "OLD", Callsign = "DL1ABC" }, []));
}
}