Add the core, contest, storage and format layers
Frequencies, bands, modes, callsigns, grid squares and the country file live in Nonemm.Core. Nonemm.Contests holds the scoring engine and CQ WW and CQ WPX. Nonemm.Storage writes N1MM's DXLOG schema, and Nonemm.Formats writes Cabrillo 3.0 and reads and writes ADIF. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
using System.Globalization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Adif;
|
||||
|
||||
/// Reads ADIF into contacts. Fields the log has no room for are dropped rather
|
||||
/// than crammed into a comment.
|
||||
public static class AdifReader
|
||||
{
|
||||
public static IReadOnlyList<Qso> Read(string text, string contestName = "DX", int contestNumber = 1)
|
||||
{
|
||||
List<Qso> qsos = [];
|
||||
foreach (Dictionary<string, string> record in Records(text))
|
||||
{
|
||||
Qso? qso = ToQso(record, contestName, contestNumber);
|
||||
if (qso is not null)
|
||||
{
|
||||
qsos.Add(qso);
|
||||
}
|
||||
}
|
||||
return qsos;
|
||||
}
|
||||
|
||||
/// The fields of each record, keyed by upper-case field name.
|
||||
public static IEnumerable<Dictionary<string, string>> Records(string text)
|
||||
{
|
||||
int at = text.IndexOf("<EOH>", StringComparison.OrdinalIgnoreCase);
|
||||
int position = at < 0 ? 0 : at + 5;
|
||||
Dictionary<string, string> record = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
while (position < text.Length)
|
||||
{
|
||||
int open = text.IndexOf('<', position);
|
||||
if (open < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
int close = text.IndexOf('>', open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
string[] parts = text[(open + 1)..close].Split(':');
|
||||
string name = parts[0].Trim();
|
||||
if (name.Equals("EOR", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (record.Count > 0)
|
||||
{
|
||||
yield return record;
|
||||
}
|
||||
record = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
position = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (parts.Length < 2 || !int.TryParse(parts[1], out int length) || length < 0)
|
||||
{
|
||||
position = close + 1;
|
||||
continue;
|
||||
}
|
||||
int end = Math.Min(close + 1 + length, text.Length);
|
||||
record[name] = text[(close + 1)..end];
|
||||
position = end;
|
||||
}
|
||||
if (record.Count > 0)
|
||||
{
|
||||
yield return record;
|
||||
}
|
||||
}
|
||||
|
||||
private static Qso? ToQso(Dictionary<string, string> record, string contestName, int contestNumber)
|
||||
{
|
||||
if (!record.TryGetValue("CALL", out string? call) || call.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
DateTime when = ParseTimestamp(record);
|
||||
return new Qso
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = when,
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = ParseFrequency(record),
|
||||
Mode = Modes.Parse(Value(record, "MODE")) ?? Modes.Digital,
|
||||
ContestName = Value(record, "CONTEST_ID") is { Length: > 0 } id ? id : contestName,
|
||||
ContestNumber = contestNumber,
|
||||
SentReport = Value(record, "RST_SENT"),
|
||||
ReceivedReport = Value(record, "RST_RCVD"),
|
||||
SentNumber = Integer(record, "STX"),
|
||||
ReceivedNumber = Integer(record, "SRX"),
|
||||
Zone = Integer(record, "CQZ"),
|
||||
Section = Value(record, "ARRL_SECT"),
|
||||
Name = Value(record, "NAME"),
|
||||
Qth = Value(record, "QTH"),
|
||||
GridSquare = Value(record, "GRIDSQUARE"),
|
||||
Comment = Value(record, "COMMENT"),
|
||||
Operator = Value(record, "OPERATOR"),
|
||||
};
|
||||
}
|
||||
|
||||
private static Frequency ParseFrequency(Dictionary<string, string> record)
|
||||
{
|
||||
if (record.TryGetValue("FREQ", out string? megahertz) &&
|
||||
double.TryParse(megahertz, NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
|
||||
{
|
||||
return Frequency.FromMegahertz(value);
|
||||
}
|
||||
Band? band = Bands.Named(Value(record, "BAND"));
|
||||
return band?.Low ?? Frequency.Zero;
|
||||
}
|
||||
|
||||
private static DateTime ParseTimestamp(Dictionary<string, string> record)
|
||||
{
|
||||
string date = Value(record, "QSO_DATE");
|
||||
string time = Value(record, "TIME_ON").PadRight(6, '0');
|
||||
return DateTime.TryParseExact(
|
||||
date + time[..6],
|
||||
"yyyyMMddHHmmss",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime when)
|
||||
? when
|
||||
: default;
|
||||
}
|
||||
|
||||
private static string Value(Dictionary<string, string> record, string name) =>
|
||||
record.TryGetValue(name, out string? value) ? value.Trim() : "";
|
||||
|
||||
private static int Integer(Dictionary<string, string> record, string name) =>
|
||||
int.TryParse(Value(record, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Adif;
|
||||
|
||||
/// Writes ADIF 3.1.4, which is what logbook and award programs read.
|
||||
public sealed class AdifWriter
|
||||
{
|
||||
private readonly StationInfo me;
|
||||
|
||||
public AdifWriter(StationInfo me) => this.me = me;
|
||||
|
||||
public string Write(IEnumerable<Qso> qsos)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
text.Append("Nonemm ADIF export\r\n");
|
||||
Field(text, "ADIF_VER", "3.1.4");
|
||||
Field(text, "PROGRAMID", "Nonemm");
|
||||
text.Append("<EOH>\r\n");
|
||||
foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc))
|
||||
{
|
||||
text.Append(Record(qso)).Append("\r\n");
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public string Record(Qso qso)
|
||||
{
|
||||
StringBuilder line = new();
|
||||
Field(line, "CALL", qso.Call.Text);
|
||||
Field(line, "QSO_DATE", qso.TimestampUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture));
|
||||
Field(line, "TIME_ON", qso.TimestampUtc.ToString("HHmmss", CultureInfo.InvariantCulture));
|
||||
Field(line, "BAND", qso.Band?.Name.ToLowerInvariant() ?? "");
|
||||
Field(line, "FREQ", (qso.Frequency.Megahertz).ToString("0.000000", CultureInfo.InvariantCulture));
|
||||
Field(line, "MODE", qso.Mode.AdifName);
|
||||
Field(line, "RST_SENT", qso.SentReport);
|
||||
Field(line, "RST_RCVD", qso.ReceivedReport);
|
||||
Field(line, "STX", qso.SentNumber > 0 ? qso.SentNumber.ToString(CultureInfo.InvariantCulture) : "");
|
||||
Field(line, "SRX", qso.ReceivedNumber > 0 ? qso.ReceivedNumber.ToString(CultureInfo.InvariantCulture) : "");
|
||||
Field(line, "CONTEST_ID", qso.ContestName);
|
||||
Field(line, "OPERATOR", qso.Operator.Length > 0 ? qso.Operator : me.Callsign);
|
||||
Field(line, "STATION_CALLSIGN", me.Callsign);
|
||||
Field(line, "NAME", qso.Name);
|
||||
Field(line, "QTH", qso.Qth);
|
||||
Field(line, "GRIDSQUARE", qso.GridSquare);
|
||||
Field(line, "CQZ", qso.Zone > 0 ? qso.Zone.ToString(CultureInfo.InvariantCulture) : "");
|
||||
Field(line, "ARRL_SECT", qso.Section);
|
||||
Field(line, "COMMENT", qso.Comment);
|
||||
line.Append("<EOR>");
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
private static void Field(StringBuilder text, string name, string value)
|
||||
{
|
||||
if (value.Length > 0)
|
||||
{
|
||||
text.Append(CultureInfo.InvariantCulture, $"<{name}:{value.Length}>{value} ");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Globalization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Cabrillo;
|
||||
|
||||
/// The frequency column of a QSO line: kilohertz up to 30 MHz, and the
|
||||
/// sponsor's band designator above it.
|
||||
public static class CabrilloBands
|
||||
{
|
||||
private static readonly Dictionary<string, string> Designators = new(StringComparer.Ordinal)
|
||||
{
|
||||
["6M"] = "50",
|
||||
["4M"] = "70",
|
||||
["2M"] = "144",
|
||||
["1.25M"] = "222",
|
||||
["70CM"] = "432",
|
||||
["33CM"] = "902",
|
||||
["23CM"] = "1.2G",
|
||||
["13CM"] = "2.3G",
|
||||
["9CM"] = "3.4G",
|
||||
["6CM"] = "5.7G",
|
||||
["3CM"] = "10G",
|
||||
["1.25CM"] = "24G",
|
||||
["6MM"] = "47G",
|
||||
["4MM"] = "75G",
|
||||
["2.5MM"] = "123G",
|
||||
["2MM"] = "134G",
|
||||
["1MM"] = "241G",
|
||||
};
|
||||
|
||||
public static string Designator(Frequency frequency)
|
||||
{
|
||||
if (frequency.Hertz < 30_000_000)
|
||||
{
|
||||
return ((long)Math.Round(frequency.Kilohertz)).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
Band? band = Bands.ForFrequency(frequency);
|
||||
return band is not null && Designators.TryGetValue(band.Name, out string? designator)
|
||||
? designator
|
||||
: ((long)Math.Round(frequency.Kilohertz)).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Nonemm.Formats.Cabrillo;
|
||||
|
||||
/// The header lines of a Cabrillo log. The sponsor rejects a log whose contest
|
||||
/// name or categories it does not recognise, so these come from the entry the
|
||||
/// operator chose rather than being guessed.
|
||||
public sealed record CabrilloHeader
|
||||
{
|
||||
public required string Contest { get; init; }
|
||||
|
||||
public required string Callsign { get; init; }
|
||||
|
||||
public string OperatorCategory { get; init; } = "SINGLE-OP";
|
||||
|
||||
public string AssistedCategory { get; init; } = "NON-ASSISTED";
|
||||
|
||||
public string BandCategory { get; init; } = "ALL";
|
||||
|
||||
public string ModeCategory { get; init; } = "";
|
||||
|
||||
public string PowerCategory { get; init; } = "HIGH";
|
||||
|
||||
public string StationCategory { get; init; } = "";
|
||||
|
||||
public string TransmitterCategory { get; init; } = "ONE";
|
||||
|
||||
public string OverlayCategory { get; init; } = "";
|
||||
|
||||
public string TimeCategory { get; init; } = "";
|
||||
|
||||
public long ClaimedScore { get; init; }
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public IReadOnlyList<string> Address { get; init; } = [];
|
||||
|
||||
public string AddressCity { get; init; } = "";
|
||||
|
||||
public string AddressStateProvince { get; init; } = "";
|
||||
|
||||
public string AddressPostalcode { get; init; } = "";
|
||||
|
||||
public string AddressCountry { get; init; } = "";
|
||||
|
||||
public string Operators { get; init; } = "";
|
||||
|
||||
public string Soapbox { get; init; } = "";
|
||||
|
||||
public string Email { get; init; } = "";
|
||||
|
||||
public string CreatedBy { get; init; } = "Nonemm";
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Cabrillo;
|
||||
|
||||
/// Writes a Cabrillo 3.0 log: the header the sponsor's robot reads, then one
|
||||
/// line per contact.
|
||||
public sealed class CabrilloWriter
|
||||
{
|
||||
private readonly Contest contest;
|
||||
private readonly StationInfo me;
|
||||
|
||||
public CabrilloWriter(Contest contest, StationInfo me)
|
||||
{
|
||||
this.contest = contest;
|
||||
this.me = me;
|
||||
}
|
||||
|
||||
public string Write(CabrilloHeader header, IEnumerable<Qso> qsos)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
text.Append("START-OF-LOG: 3.0\r\n");
|
||||
Line(text, "CONTEST", header.Contest);
|
||||
Line(text, "CALLSIGN", header.Callsign);
|
||||
Line(text, "CATEGORY-OPERATOR", header.OperatorCategory);
|
||||
Line(text, "CATEGORY-ASSISTED", header.AssistedCategory);
|
||||
Line(text, "CATEGORY-BAND", header.BandCategory);
|
||||
Line(text, "CATEGORY-MODE", header.ModeCategory);
|
||||
Line(text, "CATEGORY-POWER", header.PowerCategory);
|
||||
Line(text, "CATEGORY-STATION", header.StationCategory);
|
||||
Line(text, "CATEGORY-TRANSMITTER", header.TransmitterCategory);
|
||||
Line(text, "CATEGORY-OVERLAY", header.OverlayCategory);
|
||||
Line(text, "CATEGORY-TIME", header.TimeCategory);
|
||||
text.Append(CultureInfo.InvariantCulture, $"CLAIMED-SCORE: {header.ClaimedScore}\r\n");
|
||||
Line(text, "CLUB", header.Club);
|
||||
Line(text, "NAME", header.Name);
|
||||
foreach (string line in header.Address)
|
||||
{
|
||||
Line(text, "ADDRESS", line);
|
||||
}
|
||||
Line(text, "ADDRESS-CITY", header.AddressCity);
|
||||
Line(text, "ADDRESS-STATE-PROVINCE", header.AddressStateProvince);
|
||||
Line(text, "ADDRESS-POSTALCODE", header.AddressPostalcode);
|
||||
Line(text, "ADDRESS-COUNTRY", header.AddressCountry);
|
||||
Line(text, "EMAIL", header.Email);
|
||||
Line(text, "OPERATORS", header.Operators);
|
||||
foreach (string line in SplitLines(header.Soapbox))
|
||||
{
|
||||
Line(text, "SOAPBOX", line);
|
||||
}
|
||||
Line(text, "CREATED-BY", header.CreatedBy);
|
||||
|
||||
foreach (Qso qso in qsos.Where(q => q.IsClaimed).OrderBy(q => q.TimestampUtc))
|
||||
{
|
||||
text.Append(QsoLine(qso)).Append("\r\n");
|
||||
}
|
||||
text.Append("END-OF-LOG:\r\n");
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public string QsoLine(Qso qso)
|
||||
{
|
||||
CabrilloExchange exchange = contest.CabrilloExchange(qso, me);
|
||||
StringBuilder line = new("QSO: ");
|
||||
line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' ');
|
||||
line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(me.Callsign.PadRight(13)).Append(' ');
|
||||
Append(line, exchange.Sent);
|
||||
line.Append(qso.Call.Text.PadRight(13)).Append(' ');
|
||||
Append(line, exchange.Received);
|
||||
line.Append(qso.RadioNumber > 1 ? '1' : '0');
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
private static void Append(StringBuilder line, IReadOnlyList<CabrilloField> fields)
|
||||
{
|
||||
foreach (CabrilloField field in fields)
|
||||
{
|
||||
line.Append(field.Value.PadRight(field.Width)).Append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
private static void Line(StringBuilder text, string tag, string value)
|
||||
{
|
||||
if (value.Length > 0)
|
||||
{
|
||||
text.Append(tag).Append(": ").Append(value).Append("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitLines(string text) =>
|
||||
text.Length == 0 ? [] : text.Split('\n').Select(l => l.TrimEnd('\r'));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user