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:
10
src/Nonemm.Core/Band.cs
Normal file
10
src/Nonemm.Core/Band.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// One amateur band. Edges are the union of the three ITU regions, so a
|
||||
/// frequency legal anywhere in the world lands on a band.
|
||||
public sealed record Band(string Name, double MegahertzLabel, Frequency Low, Frequency High)
|
||||
{
|
||||
public bool Contains(Frequency f) => f >= Low && f <= High;
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
81
src/Nonemm.Core/Bands.cs
Normal file
81
src/Nonemm.Core/Bands.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The amateur bands, and the mapping between a frequency and its band.
|
||||
public static class Bands
|
||||
{
|
||||
public static readonly Band Band2190M = Make("2190M", 0.136, 135.7, 137.8);
|
||||
public static readonly Band Band630M = Make("630M", 0.472, 472, 479);
|
||||
public static readonly Band Band160M = Make("160M", 1.8, 1_800, 2_000);
|
||||
public static readonly Band Band80M = Make("80M", 3.5, 3_500, 4_000);
|
||||
public static readonly Band Band60M = Make("60M", 5, 5_000, 5_500);
|
||||
public static readonly Band Band40M = Make("40M", 7, 7_000, 7_300);
|
||||
public static readonly Band Band30M = Make("30M", 10, 10_100, 10_150);
|
||||
public static readonly Band Band20M = Make("20M", 14, 14_000, 14_350);
|
||||
public static readonly Band Band17M = Make("17M", 18, 18_068, 18_168);
|
||||
public static readonly Band Band15M = Make("15M", 21, 21_000, 21_450);
|
||||
public static readonly Band Band12M = Make("12M", 24, 24_890, 24_990);
|
||||
public static readonly Band Band10M = Make("10M", 28, 28_000, 29_700);
|
||||
public static readonly Band Band6M = Make("6M", 50, 50_000, 54_000);
|
||||
public static readonly Band Band4M = Make("4M", 70, 70_000, 70_500);
|
||||
public static readonly Band Band2M = Make("2M", 144, 144_000, 148_000);
|
||||
public static readonly Band Band125CM = Make("1.25M", 222, 222_000, 225_000);
|
||||
public static readonly Band Band70CM = Make("70CM", 420, 420_000, 450_000);
|
||||
public static readonly Band Band33CM = Make("33CM", 902, 902_000, 928_000);
|
||||
public static readonly Band Band23CM = Make("23CM", 1240, 1_240_000, 1_300_000);
|
||||
public static readonly Band Band13CM = Make("13CM", 2300, 2_300_000, 2_450_000);
|
||||
public static readonly Band Band9CM = Make("9CM", 3300, 3_300_000, 3_500_000);
|
||||
public static readonly Band Band6CM = Make("6CM", 5650, 5_650_000, 5_925_000);
|
||||
public static readonly Band Band3CM = Make("3CM", 10000, 10_000_000, 10_500_000);
|
||||
public static readonly Band Band125CMM = Make("1.25CM", 24000, 24_000_000, 24_250_000);
|
||||
public static readonly Band Band6MM = Make("6MM", 47000, 47_000_000, 47_200_000);
|
||||
public static readonly Band Band4MM = Make("4MM", 76000, 75_500_000, 81_000_000);
|
||||
public static readonly Band Band2P5MM = Make("2.5MM", 122250, 122_250_000, 123_000_000);
|
||||
public static readonly Band Band2MM = Make("2MM", 134000, 134_000_000, 141_000_000);
|
||||
public static readonly Band Band1MM = Make("1MM", 241000, 241_000_000, 250_000_000);
|
||||
|
||||
public static readonly IReadOnlyList<Band> All =
|
||||
[
|
||||
Band2190M, Band630M, Band160M, Band80M, Band60M, Band40M, Band30M, Band20M,
|
||||
Band17M, Band15M, Band12M, Band10M, Band6M, Band4M, Band2M, Band125CM,
|
||||
Band70CM, Band33CM, Band23CM, Band13CM, Band9CM, Band6CM, Band3CM,
|
||||
Band125CMM, Band6MM, Band4MM, Band2P5MM, Band2MM, Band1MM,
|
||||
];
|
||||
|
||||
/// The bands a contest normally runs on, in the order operators list them.
|
||||
public static readonly IReadOnlyList<Band> Contest =
|
||||
[Band160M, Band80M, Band40M, Band20M, Band15M, Band10M];
|
||||
|
||||
private static readonly Dictionary<string, Band> ByName =
|
||||
All.ToDictionary(b => b.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static Band Make(string name, double label, double lowKhz, double highKhz) =>
|
||||
new(name, label, Frequency.FromKilohertz(lowKhz), Frequency.FromKilohertz(highKhz));
|
||||
|
||||
/// Null when the frequency is outside every amateur allocation.
|
||||
public static Band? ForFrequency(Frequency f)
|
||||
{
|
||||
foreach (Band band in All)
|
||||
{
|
||||
if (band.Contains(f))
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Band? ByLabel(double megahertzLabel)
|
||||
{
|
||||
foreach (Band band in All)
|
||||
{
|
||||
if (Math.Abs(band.MegahertzLabel - megahertzLabel) < 0.0001)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Band? Named(string name) =>
|
||||
ByName.TryGetValue(name, out Band? band) ? band : null;
|
||||
}
|
||||
118
src/Nonemm.Core/Callsign.cs
Normal file
118
src/Nonemm.Core/Callsign.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// A callsign split into the parts contest rules care about: the station's own
|
||||
/// call, a portable prefix, and modifiers such as /P or /MM.
|
||||
public sealed record Callsign
|
||||
{
|
||||
private static readonly HashSet<string> PlainModifiers =
|
||||
new(StringComparer.Ordinal) { "P", "M", "A", "AM", "MM", "QRP", "LH", "J", "R", "B", "N", "T" };
|
||||
|
||||
private Callsign(string text, string station, string? portablePrefix, IReadOnlyList<string> modifiers)
|
||||
{
|
||||
Text = text;
|
||||
Station = station;
|
||||
PortablePrefix = portablePrefix;
|
||||
Modifiers = modifiers;
|
||||
}
|
||||
|
||||
/// The whole thing as typed, upper case.
|
||||
public string Text { get; }
|
||||
|
||||
/// The operator's own callsign, with prefix and modifiers removed.
|
||||
public string Station { get; }
|
||||
|
||||
/// The prefix the operator is signing from, e.g. `KH9` in `KH9/N8BJQ`.
|
||||
public string? PortablePrefix { get; }
|
||||
|
||||
public IReadOnlyList<string> Modifiers { get; }
|
||||
|
||||
public bool IsMaritimeMobile => Modifiers.Contains("MM");
|
||||
|
||||
public bool IsAeronauticalMobile => Modifiers.Contains("AM");
|
||||
|
||||
/// Maritime and aeronautical mobile stations count for no country and no
|
||||
/// prefix, so most contests score them zero.
|
||||
public bool CountsForEntity => !IsMaritimeMobile && !IsAeronauticalMobile;
|
||||
|
||||
public static Callsign Parse(string text)
|
||||
{
|
||||
string cleaned = text.Trim().ToUpperInvariant();
|
||||
string[] parts = cleaned.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return new Callsign(cleaned, cleaned, null, []);
|
||||
}
|
||||
|
||||
List<string> modifiers = [];
|
||||
List<string> callParts = [];
|
||||
foreach (string part in parts)
|
||||
{
|
||||
if (IsPlainModifier(part) && callParts.Count > 0)
|
||||
{
|
||||
modifiers.Add(part);
|
||||
}
|
||||
else
|
||||
{
|
||||
callParts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (callParts.Count == 0)
|
||||
{
|
||||
return new Callsign(cleaned, cleaned, null, modifiers);
|
||||
}
|
||||
if (callParts.Count == 1)
|
||||
{
|
||||
return new Callsign(cleaned, callParts[0], null, modifiers);
|
||||
}
|
||||
|
||||
// With two candidate parts the shorter one is the location prefix;
|
||||
// equal lengths are read as prefix first, which is how calls are signed now.
|
||||
(string prefix, string station) = callParts[0].Length <= callParts[1].Length
|
||||
? (callParts[0], callParts[1])
|
||||
: (callParts[1], callParts[0]);
|
||||
return new Callsign(cleaned, station, prefix, modifiers);
|
||||
}
|
||||
|
||||
/// The CQ WPX prefix, or null for a station that counts for no prefix.
|
||||
public string? WpxPrefix()
|
||||
{
|
||||
if (!CountsForEntity)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (PortablePrefix is not null)
|
||||
{
|
||||
return PortablePrefix.Any(char.IsDigit) ? PortablePrefix : PortablePrefix + "0";
|
||||
}
|
||||
string? digitModifier = Modifiers.FirstOrDefault(m => m.Length == 1 && char.IsDigit(m[0]));
|
||||
string prefix = PrefixOf(Station);
|
||||
if (digitModifier is not null && prefix.Length > 0 && char.IsDigit(prefix[^1]))
|
||||
{
|
||||
return prefix[..^1] + digitModifier;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
/// What to hand the country file: the portable prefix when there is one,
|
||||
/// because that is where the station is.
|
||||
public string EntityLookupText() =>
|
||||
PortablePrefix is null ? Station : PortablePrefix;
|
||||
|
||||
public override string ToString() => Text;
|
||||
|
||||
private static bool IsPlainModifier(string part) =>
|
||||
PlainModifiers.Contains(part) || (part.Length == 1 && char.IsDigit(part[0]));
|
||||
|
||||
/// Everything up to and including the last digit. A call with no digit at
|
||||
/// all takes a zero after its first two letters, as the WPX rules say.
|
||||
private static string PrefixOf(string call)
|
||||
{
|
||||
int lastDigit = call.LastIndexOfAny("0123456789".ToCharArray());
|
||||
if (lastDigit < 0)
|
||||
{
|
||||
return (call.Length <= 2 ? call : call[..2]) + "0";
|
||||
}
|
||||
return call[..(lastDigit + 1)];
|
||||
}
|
||||
}
|
||||
147
src/Nonemm.Core/Country/CountryFile.cs
Normal file
147
src/Nonemm.Core/Country/CountryFile.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// The country file (`cty.dat` or `wl_cty.dat`), which says which entity, zone
|
||||
/// and continent a callsign belongs to.
|
||||
public sealed class CountryFile
|
||||
{
|
||||
private readonly Dictionary<string, PrefixRule> exactCalls;
|
||||
private readonly Dictionary<string, PrefixRule> prefixes;
|
||||
private readonly int longestPrefix;
|
||||
|
||||
private CountryFile(
|
||||
IReadOnlyList<DxccEntity> entities,
|
||||
Dictionary<string, PrefixRule> exactCalls,
|
||||
Dictionary<string, PrefixRule> prefixes)
|
||||
{
|
||||
Entities = entities;
|
||||
this.exactCalls = exactCalls;
|
||||
this.prefixes = prefixes;
|
||||
longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DxccEntity> Entities { get; }
|
||||
|
||||
/// Throws `FormatException` when the text is not a country file, so a
|
||||
/// download that returned an error page cannot replace a good file.
|
||||
public static CountryFile Parse(string text)
|
||||
{
|
||||
List<DxccEntity> entities = [];
|
||||
Dictionary<string, PrefixRule> exactCalls = new(StringComparer.Ordinal);
|
||||
Dictionary<string, PrefixRule> prefixes = new(StringComparer.Ordinal);
|
||||
DxccEntity? current = null;
|
||||
|
||||
foreach (string rawLine in text.Split('\n'))
|
||||
{
|
||||
string line = rawLine.TrimEnd('\r', ' ', '\t');
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!char.IsWhiteSpace(rawLine[0]))
|
||||
{
|
||||
current = ParseHeader(line);
|
||||
entities.Add(current);
|
||||
continue;
|
||||
}
|
||||
if (current is null)
|
||||
{
|
||||
throw new FormatException($"country file starts with an alias line: '{line}'");
|
||||
}
|
||||
foreach (string alias in line.TrimEnd(';').Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
AddAlias(current, alias.Trim(), exactCalls, prefixes);
|
||||
}
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
throw new FormatException("country file holds no entities");
|
||||
}
|
||||
return new CountryFile(entities, exactCalls, prefixes);
|
||||
}
|
||||
|
||||
public CountryLookup? Find(Callsign call)
|
||||
{
|
||||
if (!call.CountsForEntity)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (exactCalls.TryGetValue(call.Text, out PrefixRule? whole))
|
||||
{
|
||||
return whole.Resolve(call.Text);
|
||||
}
|
||||
if (exactCalls.TryGetValue(call.Station, out PrefixRule? station) && call.PortablePrefix is null)
|
||||
{
|
||||
return station.Resolve(call.Station);
|
||||
}
|
||||
return FindByPrefix(call.EntityLookupText());
|
||||
}
|
||||
|
||||
public CountryLookup? Find(string call) => Find(Callsign.Parse(call));
|
||||
|
||||
private CountryLookup? FindByPrefix(string text)
|
||||
{
|
||||
for (int length = Math.Min(text.Length, longestPrefix); length > 0; length--)
|
||||
{
|
||||
if (prefixes.TryGetValue(text[..length], out PrefixRule? rule))
|
||||
{
|
||||
return rule.Resolve(text[..length]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddAlias(
|
||||
DxccEntity entity,
|
||||
string alias,
|
||||
Dictionary<string, PrefixRule> exactCalls,
|
||||
Dictionary<string, PrefixRule> prefixes)
|
||||
{
|
||||
if (alias.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool isWholeCall = alias[0] == '=';
|
||||
PrefixRule rule = PrefixRule.Parse(entity, isWholeCall ? alias[1..] : alias);
|
||||
if (rule.Key.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Dictionary<string, PrefixRule> target = isWholeCall ? exactCalls : prefixes;
|
||||
target[rule.Key] = rule;
|
||||
}
|
||||
|
||||
private static DxccEntity ParseHeader(string line)
|
||||
{
|
||||
string[] fields = line.TrimEnd(':').Split(':');
|
||||
if (fields.Length != 8)
|
||||
{
|
||||
throw new FormatException($"country file header needs 8 fields, got {fields.Length}: '{line}'");
|
||||
}
|
||||
string primary = fields[7].Trim();
|
||||
bool waeOnly = primary.StartsWith('*');
|
||||
return new DxccEntity(
|
||||
Name: fields[0].Trim(),
|
||||
PrimaryPrefix: waeOnly ? primary[1..] : primary,
|
||||
CqZone: ParseInt(fields[1], line),
|
||||
ItuZone: ParseInt(fields[2], line),
|
||||
Continent: fields[3].Trim(),
|
||||
Latitude: ParseDouble(fields[4], line),
|
||||
// the file gives longitude west-positive; the rest of the program wants east-positive
|
||||
Longitude: -ParseDouble(fields[5], line),
|
||||
UtcOffset: ParseDouble(fields[6], line),
|
||||
IsWaeOnly: waeOnly);
|
||||
}
|
||||
|
||||
private static int ParseInt(string field, string line) =>
|
||||
int.TryParse(field.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: throw new FormatException($"country file field '{field.Trim()}' is not a number in '{line}'");
|
||||
|
||||
private static double ParseDouble(string field, string line) =>
|
||||
double.TryParse(field.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
|
||||
? value
|
||||
: throw new FormatException($"country file field '{field.Trim()}' is not a number in '{line}'");
|
||||
}
|
||||
12
src/Nonemm.Core/Country/CountryLookup.cs
Normal file
12
src/Nonemm.Core/Country/CountryLookup.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// What the country file says about one callsign. Zone and continent can differ
|
||||
/// from the entity's own when the country file overrides them for that prefix.
|
||||
public sealed record CountryLookup(
|
||||
DxccEntity Entity,
|
||||
string MatchedPrefix,
|
||||
int CqZone,
|
||||
int ItuZone,
|
||||
string Continent,
|
||||
double Latitude,
|
||||
double Longitude);
|
||||
17
src/Nonemm.Core/Country/DxccEntity.cs
Normal file
17
src/Nonemm.Core/Country/DxccEntity.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// One entity from the country file. `IsWaeOnly` marks the entities that only
|
||||
/// the WAE list counts separately, such as the Shetlands apart from Scotland.
|
||||
public sealed record DxccEntity(
|
||||
string Name,
|
||||
string PrimaryPrefix,
|
||||
int CqZone,
|
||||
int ItuZone,
|
||||
string Continent,
|
||||
double Latitude,
|
||||
double Longitude,
|
||||
double UtcOffset,
|
||||
bool IsWaeOnly)
|
||||
{
|
||||
public override string ToString() => PrimaryPrefix;
|
||||
}
|
||||
113
src/Nonemm.Core/Country/PrefixRule.cs
Normal file
113
src/Nonemm.Core/Country/PrefixRule.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// One alias from the country file: a prefix or whole call, plus the overrides
|
||||
/// the file writes after it, such as `K5(4)[7]` for a different zone.
|
||||
public sealed class PrefixRule
|
||||
{
|
||||
private readonly DxccEntity entity;
|
||||
private readonly int? cqZone;
|
||||
private readonly int? ituZone;
|
||||
private readonly string? continent;
|
||||
private readonly double? latitude;
|
||||
private readonly double? longitude;
|
||||
|
||||
private PrefixRule(
|
||||
string key,
|
||||
DxccEntity entity,
|
||||
int? cqZone,
|
||||
int? ituZone,
|
||||
string? continent,
|
||||
double? latitude,
|
||||
double? longitude)
|
||||
{
|
||||
Key = key;
|
||||
this.entity = entity;
|
||||
this.cqZone = cqZone;
|
||||
this.ituZone = ituZone;
|
||||
this.continent = continent;
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public static PrefixRule Parse(DxccEntity entity, string alias)
|
||||
{
|
||||
string key = alias;
|
||||
int? cq = null;
|
||||
int? itu = null;
|
||||
string? continent = null;
|
||||
double? latitude = null;
|
||||
double? longitude = null;
|
||||
|
||||
while (key.Length > 0)
|
||||
{
|
||||
int open = key.IndexOfAny(['(', '[', '<', '{', '~']);
|
||||
if (open < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
char closer = key[open] switch
|
||||
{
|
||||
'(' => ')',
|
||||
'[' => ']',
|
||||
'<' => '>',
|
||||
'{' => '}',
|
||||
_ => '~',
|
||||
};
|
||||
int close = key.IndexOf(closer, open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
throw new FormatException($"country file override in '{alias}' is not closed");
|
||||
}
|
||||
string body = key[(open + 1)..close];
|
||||
switch (key[open])
|
||||
{
|
||||
case '(':
|
||||
cq = ParseInt(body, alias);
|
||||
break;
|
||||
case '[':
|
||||
itu = ParseInt(body, alias);
|
||||
break;
|
||||
case '{':
|
||||
continent = body.Trim();
|
||||
break;
|
||||
case '<':
|
||||
(latitude, longitude) = ParseCoordinates(body, alias);
|
||||
break;
|
||||
}
|
||||
key = key.Remove(open, close - open + 1);
|
||||
}
|
||||
|
||||
return new PrefixRule(key.Trim(), entity, cq, itu, continent, latitude, longitude);
|
||||
}
|
||||
|
||||
public CountryLookup Resolve(string matched) =>
|
||||
new(
|
||||
entity,
|
||||
matched,
|
||||
cqZone ?? entity.CqZone,
|
||||
ituZone ?? entity.ItuZone,
|
||||
continent ?? entity.Continent,
|
||||
latitude ?? entity.Latitude,
|
||||
longitude ?? entity.Longitude);
|
||||
|
||||
private static int ParseInt(string body, string alias) =>
|
||||
int.TryParse(body.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: throw new FormatException($"country file override '{body}' in '{alias}' is not a number");
|
||||
|
||||
private static (double Latitude, double Longitude) ParseCoordinates(string body, string alias)
|
||||
{
|
||||
string[] parts = body.Split('/');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
throw new FormatException($"country file coordinates '{body}' in '{alias}' are not lat/long");
|
||||
}
|
||||
double latitude = double.Parse(parts[0].Trim(), CultureInfo.InvariantCulture);
|
||||
double longitude = double.Parse(parts[1].Trim(), CultureInfo.InvariantCulture);
|
||||
return (latitude, -longitude);
|
||||
}
|
||||
}
|
||||
35
src/Nonemm.Core/Frequency.cs
Normal file
35
src/Nonemm.Core/Frequency.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// A radio frequency held in hertz, so kilohertz and megahertz cannot be mixed up.
|
||||
public readonly record struct Frequency : IComparable<Frequency>
|
||||
{
|
||||
public long Hertz { get; }
|
||||
|
||||
private Frequency(long hertz) => Hertz = hertz;
|
||||
|
||||
public static readonly Frequency Zero = new(0);
|
||||
|
||||
public static Frequency FromHertz(long hertz) => new(hertz);
|
||||
|
||||
public static Frequency FromKilohertz(double kilohertz) =>
|
||||
new((long)Math.Round(kilohertz * 1_000));
|
||||
|
||||
public static Frequency FromMegahertz(double megahertz) =>
|
||||
new((long)Math.Round(megahertz * 1_000_000));
|
||||
|
||||
public double Kilohertz => Hertz / 1_000.0;
|
||||
|
||||
public double Megahertz => Hertz / 1_000_000.0;
|
||||
|
||||
public int CompareTo(Frequency other) => Hertz.CompareTo(other.Hertz);
|
||||
|
||||
public static bool operator <(Frequency a, Frequency b) => a.Hertz < b.Hertz;
|
||||
public static bool operator >(Frequency a, Frequency b) => a.Hertz > b.Hertz;
|
||||
public static bool operator <=(Frequency a, Frequency b) => a.Hertz <= b.Hertz;
|
||||
public static bool operator >=(Frequency a, Frequency b) => a.Hertz >= b.Hertz;
|
||||
|
||||
public static Frequency operator +(Frequency a, Frequency b) => new(a.Hertz + b.Hertz);
|
||||
public static Frequency operator -(Frequency a, Frequency b) => new(a.Hertz - b.Hertz);
|
||||
|
||||
public override string ToString() => Kilohertz.ToString("0.0###");
|
||||
}
|
||||
115
src/Nonemm.Core/GridSquare.cs
Normal file
115
src/Nonemm.Core/GridSquare.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// A Maidenhead locator, and the great-circle maths contests need from it.
|
||||
public readonly record struct GridSquare
|
||||
{
|
||||
private GridSquare(string text, double latitude, double longitude)
|
||||
{
|
||||
Text = text;
|
||||
Latitude = latitude;
|
||||
Longitude = longitude;
|
||||
}
|
||||
|
||||
/// The locator as given, upper case for the field and square, lower for the
|
||||
/// subsquare, which is how locators are written.
|
||||
public string Text { get; }
|
||||
|
||||
/// The centre of the square, in degrees, longitude east-positive.
|
||||
public double Latitude { get; }
|
||||
|
||||
public double Longitude { get; }
|
||||
|
||||
public static bool TryParse(string text, out GridSquare grid)
|
||||
{
|
||||
grid = default;
|
||||
string t = text.Trim();
|
||||
if (t.Length is not (4 or 6 or 8) || t.Length % 2 != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!InRange(t[0], 'A', 'R') || !InRange(t[1], 'A', 'R') ||
|
||||
!char.IsAsciiDigit(t[2]) || !char.IsAsciiDigit(t[3]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double longitude = ((Upper(t[0]) - 'A') * 20.0) + ((t[2] - '0') * 2.0);
|
||||
double latitude = ((Upper(t[1]) - 'A') * 10.0) + (t[3] - '0');
|
||||
double longitudeSize = 2.0;
|
||||
double latitudeSize = 1.0;
|
||||
|
||||
if (t.Length >= 6)
|
||||
{
|
||||
if (!InRange(t[4], 'A', 'X') || !InRange(t[5], 'A', 'X'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
longitude += (Upper(t[4]) - 'A') * (2.0 / 24.0);
|
||||
latitude += (Upper(t[5]) - 'A') * (1.0 / 24.0);
|
||||
longitudeSize = 2.0 / 24.0;
|
||||
latitudeSize = 1.0 / 24.0;
|
||||
}
|
||||
if (t.Length == 8)
|
||||
{
|
||||
if (!char.IsAsciiDigit(t[6]) || !char.IsAsciiDigit(t[7]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
longitude += (t[6] - '0') * (2.0 / 240.0);
|
||||
latitude += (t[7] - '0') * (1.0 / 240.0);
|
||||
longitudeSize = 2.0 / 240.0;
|
||||
latitudeSize = 1.0 / 240.0;
|
||||
}
|
||||
|
||||
grid = new GridSquare(
|
||||
Normalize(t),
|
||||
latitude + (latitudeSize / 2.0) - 90.0,
|
||||
longitude + (longitudeSize / 2.0) - 180.0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Great-circle distance in kilometres.
|
||||
public double DistanceTo(GridSquare other) =>
|
||||
DistanceKm(Latitude, Longitude, other.Latitude, other.Longitude);
|
||||
|
||||
/// Initial bearing in degrees, 0 at north.
|
||||
public double BearingTo(GridSquare other) =>
|
||||
Bearing(Latitude, Longitude, other.Latitude, other.Longitude);
|
||||
|
||||
public override string ToString() => Text;
|
||||
|
||||
public static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double earthRadiusKm = 6371.0;
|
||||
double dLat = Radians(lat2 - lat1);
|
||||
double dLon = Radians(lon2 - lon1);
|
||||
double a = (Math.Sin(dLat / 2) * Math.Sin(dLat / 2)) +
|
||||
(Math.Cos(Radians(lat1)) * Math.Cos(Radians(lat2)) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2));
|
||||
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
}
|
||||
|
||||
public static double Bearing(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
double dLon = Radians(lon2 - lon1);
|
||||
double y = Math.Sin(dLon) * Math.Cos(Radians(lat2));
|
||||
double x = (Math.Cos(Radians(lat1)) * Math.Sin(Radians(lat2))) -
|
||||
(Math.Sin(Radians(lat1)) * Math.Cos(Radians(lat2)) * Math.Cos(dLon));
|
||||
return ((Math.Atan2(y, x) * 180.0 / Math.PI) + 360.0) % 360.0;
|
||||
}
|
||||
|
||||
private static double Radians(double degrees) => degrees * Math.PI / 180.0;
|
||||
|
||||
private static bool InRange(char c, char low, char high)
|
||||
{
|
||||
char u = Upper(c);
|
||||
return u >= low && u <= high;
|
||||
}
|
||||
|
||||
private static char Upper(char c) => char.ToUpperInvariant(c);
|
||||
|
||||
private static string Normalize(string t) =>
|
||||
t.Length <= 4
|
||||
? t.ToUpperInvariant()
|
||||
: t[..4].ToUpperInvariant() + t[4..6].ToLowerInvariant() + t[6..].ToUpperInvariant();
|
||||
}
|
||||
7
src/Nonemm.Core/Mode.cs
Normal file
7
src/Nonemm.Core/Mode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// One operating mode. `Name` is what goes in the log and must round-trip.
|
||||
public sealed record Mode(string Name, ModeCategory Category, string CabrilloCode, string AdifName)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
9
src/Nonemm.Core/ModeCategory.cs
Normal file
9
src/Nonemm.Core/ModeCategory.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The three groups contest rules score and check dupes by.
|
||||
public enum ModeCategory
|
||||
{
|
||||
Cw,
|
||||
Phone,
|
||||
Digital,
|
||||
}
|
||||
59
src/Nonemm.Core/Modes.cs
Normal file
59
src/Nonemm.Core/Modes.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The operating modes the logger knows, and how to read one off a text field.
|
||||
public static class Modes
|
||||
{
|
||||
public static readonly Mode Cw = new("CW", ModeCategory.Cw, "CW", "CW");
|
||||
public static readonly Mode Usb = new("USB", ModeCategory.Phone, "PH", "SSB");
|
||||
public static readonly Mode Lsb = new("LSB", ModeCategory.Phone, "PH", "SSB");
|
||||
public static readonly Mode Am = new("AM", ModeCategory.Phone, "PH", "AM");
|
||||
public static readonly Mode Fm = new("FM", ModeCategory.Phone, "FM", "FM");
|
||||
public static readonly Mode Rtty = new("RTTY", ModeCategory.Digital, "RY", "RTTY");
|
||||
public static readonly Mode Psk31 = new("PSK31", ModeCategory.Digital, "DG", "PSK31");
|
||||
public static readonly Mode Psk63 = new("PSK63", ModeCategory.Digital, "DG", "PSK63");
|
||||
public static readonly Mode Ft8 = new("FT8", ModeCategory.Digital, "DG", "FT8");
|
||||
public static readonly Mode Ft4 = new("FT4", ModeCategory.Digital, "DG", "FT4");
|
||||
public static readonly Mode Mfsk = new("MFSK", ModeCategory.Digital, "DG", "MFSK");
|
||||
public static readonly Mode Jt65 = new("JT65", ModeCategory.Digital, "DG", "JT65");
|
||||
public static readonly Mode Msk144 = new("MSK144", ModeCategory.Digital, "DG", "MSK144");
|
||||
public static readonly Mode Q65 = new("Q65", ModeCategory.Digital, "DG", "Q65");
|
||||
public static readonly Mode Digital = new("DIGI", ModeCategory.Digital, "DG", "DATA");
|
||||
|
||||
public static readonly IReadOnlyList<Mode> All =
|
||||
[
|
||||
Cw, Usb, Lsb, Am, Fm, Rtty, Psk31, Psk63, Ft8, Ft4, Mfsk, Jt65, Msk144, Q65, Digital,
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, Mode> ByName =
|
||||
BuildNameIndex();
|
||||
|
||||
/// Null for text that names no mode we know. Radios report a sideband
|
||||
/// ("USB", "LSB"); logs and contest rules often say "SSB", which resolves
|
||||
/// against the band, so callers that have a frequency should use
|
||||
/// `ForSideband` instead.
|
||||
public static Mode? Parse(string text)
|
||||
{
|
||||
string key = text.Trim();
|
||||
return ByName.TryGetValue(key, out Mode? mode) ? mode : null;
|
||||
}
|
||||
|
||||
/// The sideband convention: LSB below 10 MHz, USB above, and on 60 metres.
|
||||
public static Mode ForSideband(Frequency f) =>
|
||||
f.Hertz < 10_000_000 && !Bands.Band60M.Contains(f) ? Lsb : Usb;
|
||||
|
||||
private static Dictionary<string, Mode> BuildNameIndex()
|
||||
{
|
||||
Dictionary<string, Mode> index = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Mode mode in All)
|
||||
{
|
||||
index[mode.Name] = mode;
|
||||
index.TryAdd(mode.AdifName, mode);
|
||||
}
|
||||
index["CW-R"] = Cw;
|
||||
index["RTTY-R"] = Rtty;
|
||||
index["PKT"] = Digital;
|
||||
index["DATA"] = Digital;
|
||||
index["SSB"] = Usb;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
9
src/Nonemm.Core/Nonemm.Core.csproj
Normal file
9
src/Nonemm.Core/Nonemm.Core.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
101
src/Nonemm.Core/Qso.cs
Normal file
101
src/Nonemm.Core/Qso.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// One logged contact. The fields follow N1MM's log columns so a QSO written by
|
||||
/// either program means the same thing to the other.
|
||||
public sealed record Qso
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required DateTime TimestampUtc { get; init; }
|
||||
|
||||
public required Callsign Call { get; init; }
|
||||
|
||||
public required Frequency Frequency { get; init; }
|
||||
|
||||
/// The frequency the other station transmits on when working split.
|
||||
public Frequency QsxFrequency { get; init; } = Frequency.Zero;
|
||||
|
||||
public required Mode Mode { get; init; }
|
||||
|
||||
public required string ContestName { get; init; }
|
||||
|
||||
public int ContestNumber { get; init; }
|
||||
|
||||
public string SentReport { get; init; } = "";
|
||||
|
||||
public string ReceivedReport { get; init; } = "";
|
||||
|
||||
public int SentNumber { get; init; }
|
||||
|
||||
public int ReceivedNumber { get; init; }
|
||||
|
||||
public string Section { get; init; } = "";
|
||||
|
||||
public string Precedence { get; init; } = "";
|
||||
|
||||
public int Check { get; init; }
|
||||
|
||||
public int Zone { get; init; }
|
||||
|
||||
public string Exchange1 { get; init; } = "";
|
||||
|
||||
public string MiscText { get; init; } = "";
|
||||
|
||||
public string Comment { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Qth { get; init; } = "";
|
||||
|
||||
public string Power { get; init; } = "";
|
||||
|
||||
public string GridSquare { get; init; } = "";
|
||||
|
||||
public string RoverLocation { get; init; } = "";
|
||||
|
||||
public string CountryPrefix { get; init; } = "";
|
||||
|
||||
/// The prefix the station itself is signing, which differs from
|
||||
/// `CountryPrefix` for a portable operation.
|
||||
public string StationPrefix { get; init; } = "";
|
||||
|
||||
public string WpxPrefix { get; init; } = "";
|
||||
|
||||
public string Continent { get; init; } = "";
|
||||
|
||||
public int Points { get; init; }
|
||||
|
||||
public bool IsMultiplier1 { get; init; }
|
||||
|
||||
public bool IsMultiplier2 { get; init; }
|
||||
|
||||
public bool IsMultiplier3 { get; init; }
|
||||
|
||||
public bool IsRunQso { get; init; }
|
||||
|
||||
/// N1MM's per-QSO contact type: empty for a normal contact.
|
||||
public string ContactType { get; init; } = "";
|
||||
|
||||
/// Which of a two-radio station's run positions made the contact.
|
||||
public int RunPosition { get; init; }
|
||||
|
||||
public string Operator { get; init; } = "";
|
||||
|
||||
public int RadioNumber { get; init; } = 1;
|
||||
|
||||
public bool IsRadioInterfaced { get; init; }
|
||||
|
||||
public int NetworkedComputerNumber { get; init; }
|
||||
|
||||
public string StationName { get; init; } = "";
|
||||
|
||||
/// False on a QSO this station received from another station in the network.
|
||||
public bool IsOriginal { get; init; } = true;
|
||||
|
||||
/// Cleared when the operator excludes a contact from the claimed score.
|
||||
public bool IsClaimed { get; init; } = true;
|
||||
|
||||
public Band? Band => Bands.ForFrequency(Frequency);
|
||||
|
||||
public static string NewId() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
37
src/Nonemm.Core/StationInfo.cs
Normal file
37
src/Nonemm.Core/StationInfo.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The operator's own station: what goes in the sent exchange and what contest
|
||||
/// rules compare a worked station against.
|
||||
public sealed record StationInfo
|
||||
{
|
||||
public required string Callsign { get; init; }
|
||||
|
||||
public int CqZone { get; init; }
|
||||
|
||||
public int ItuZone { get; init; }
|
||||
|
||||
public string Continent { get; init; } = "";
|
||||
|
||||
/// The primary prefix of the entity operated from, as the country file names it.
|
||||
public string CountryPrefix { get; init; } = "";
|
||||
|
||||
public string State { get; init; } = "";
|
||||
|
||||
public string Province { get; init; } = "";
|
||||
|
||||
public string ArrlSection { get; init; } = "";
|
||||
|
||||
public string GridSquare { get; init; } = "";
|
||||
|
||||
public string County { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Power { get; init; } = "";
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
public double Latitude { get; init; }
|
||||
|
||||
public double Longitude { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user