Paint the band the way N1MM paints it
Behind the bandmap scale, the CW part of the band is one shade and the phone part another, with a third for a digital block. That is what N1MM's graphical bandmap does: ResizeBandPanels puts a CW, a Digi and an SSB panel behind the scale, sized from its CWBands, DigiBands and SSBBands tables. Anything past a band edge is shaded red, which is the only place the edges themselves show, and it only appears now that the slice can reach past an edge. The defaults are N1MM's: CW ends at 1840, 3600, 7125, 14150 and so on, and the digital pair is empty until somebody fills it in, exactly as N1MM ships. They are one worldwide set rather than one per ITU region, which is also N1MM's answer — the reason recorded for leaving this out, that the segments differ by region, was wrong about what N1MM holds. 60M and everything above 2M get no entry, because N1MM's own numbers there contradict themselves: its 1.25M CW top is below its phone start, and its 70CM CW top is above the band. Config ▸ Sub bands edits them, one row per band in kilohertz, with a Defaults button. Only the rows that differ are written to the settings file, so a plan nobody has touched leaves nothing behind. BandPlan is an object rather than a static table, so the stored changes layer over the defaults and the bandmap picks up a save without a restart. The numbers are a drawing aid, not a licence. Nothing stops a contact being logged either side of a boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
17
README.md
17
README.md
@@ -229,8 +229,21 @@ The bars on the scale are as wide as the mode passes: green where you are
|
||||
listening, red where the radio transmits when working split, orange for the
|
||||
other radio of a two-radio station.
|
||||
|
||||
Band-plan colouring of the scale is not there. The segments differ by ITU region
|
||||
and the program has no band-plan table, so it would be guesswork.
|
||||
Behind the scale the CW part of the band is one shade and the phone part
|
||||
another, with a third shade for a digital block where one is set, and the CW
|
||||
boundary named in the status line. Anything past a band edge is shaded red,
|
||||
which is where the edges show. N1MM paints the same blocks from its `CWBands`,
|
||||
`DigiBands` and `SSBBands` tables.
|
||||
|
||||
**Config → Sub bands** edits them, one row per band, in kilohertz, with a
|
||||
Defaults button. The digital pair starts empty, as N1MM's does, so nothing is
|
||||
painted for it until it is filled in. Only the rows that differ from the
|
||||
defaults are written to the settings file.
|
||||
|
||||
The defaults are one worldwide set, as N1MM's are, not one per ITU region. They
|
||||
are a drawing aid: a contact logs either side of a boundary just the same.
|
||||
Bands where N1MM's own numbers contradict themselves — 60M, and everything
|
||||
above 2M — get no shading rather than a guess.
|
||||
|
||||
### Band edges
|
||||
|
||||
|
||||
@@ -45,10 +45,6 @@ data. The file is read without them.
|
||||
the contest rules score it, but there is no digital window: no decoding, no
|
||||
transmitting, no interface to fldigi, MMTTY or similar.
|
||||
|
||||
**Band-plan colouring of the bandmap scale.** N1MM colours the scale by CW,
|
||||
digital and phone segment. The segments differ by ITU region and there is no
|
||||
band-plan table here, so it is left out rather than guessed.
|
||||
|
||||
**Contest coverage.** Eight contests are built in, plus whatever `.udc` files
|
||||
are in the user-defined folder. N1MM ships well over a hundred. Opening a log
|
||||
from a contest not in the registry fails with the contest name, which is the
|
||||
@@ -64,6 +60,12 @@ thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it.
|
||||
That is correct but heavy, and it discards what is typed in an entry window on
|
||||
another radio.
|
||||
|
||||
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no
|
||||
CW, digital or phone shading on the bandmap, because N1MM's own numbers for
|
||||
those bands contradict themselves — its CW top for 1.25M is below its phone
|
||||
start, and its 70CM CW top is above the band. Config ▸ Sub bands cannot add a
|
||||
band that has no default.
|
||||
|
||||
**Available mults and Qs is only as good as the bandmap.** It lists what is
|
||||
spotted, so a multiplier nobody has spotted is not there. N1MM's window also
|
||||
shows the mults a contest has and nobody has worked, which needs a list of every
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed class AppSession : IDisposable
|
||||
History = LoadCallHistory(settings.CallHistoryFile);
|
||||
Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList<string> problems);
|
||||
UserDefinedContestProblems = problems;
|
||||
BandPlan = settings.ToBandPlan();
|
||||
}
|
||||
|
||||
public UserPaths Paths { get; }
|
||||
@@ -44,6 +45,9 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public ContestRegistry Registry { get; private set; }
|
||||
|
||||
/// Where the bandmap paints the CW, digital and phone parts of a band.
|
||||
public BandPlan BandPlan { get; private set; }
|
||||
|
||||
public IReadOnlyList<string> UserDefinedContestProblems { get; }
|
||||
|
||||
public CountryFile? Countries { get; private set; }
|
||||
@@ -101,6 +105,7 @@ public sealed class AppSession : IDisposable
|
||||
public void Save(Settings settings)
|
||||
{
|
||||
Settings = settings;
|
||||
BandPlan = settings.ToBandPlan();
|
||||
settings.Save(Paths.SettingsFile);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ public sealed record Settings
|
||||
|
||||
public int KeyerSpeed { get; init; } = 28;
|
||||
|
||||
/// Sub-band boundaries the operator has changed. Empty means the defaults
|
||||
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
||||
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
|
||||
|
||||
/// The twelve function key messages, keyed F1 to F12.
|
||||
public IReadOnlyList<string> CwMessages { get; init; } = [];
|
||||
|
||||
@@ -91,6 +95,20 @@ public sealed record Settings
|
||||
}
|
||||
}
|
||||
|
||||
/// The defaults with the operator's changes applied over them.
|
||||
public BandPlan ToBandPlan()
|
||||
{
|
||||
BandPlan plan = BandPlan.Default;
|
||||
foreach (StoredSubBand stored in SubBands)
|
||||
{
|
||||
if (stored.ToSegments() is { } segments)
|
||||
{
|
||||
plan = plan.With(segments);
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
||||
|
||||
@@ -112,6 +130,38 @@ public sealed record Settings
|
||||
};
|
||||
}
|
||||
|
||||
/// One band's sub-band boundaries as they are stored. Kilohertz, because that
|
||||
/// is what the operator types into the editor.
|
||||
public sealed record StoredSubBand
|
||||
{
|
||||
public string Band { get; init; } = "";
|
||||
|
||||
public double CwHigh { get; init; }
|
||||
|
||||
public double DigitalLow { get; init; }
|
||||
|
||||
public double DigitalHigh { get; init; }
|
||||
|
||||
/// Null when the band name is not one we know, or the CW boundary is
|
||||
/// missing: an entry that cannot be read leaves the default in place.
|
||||
public BandSegments? ToSegments() =>
|
||||
Bands.Named(Band) is { } band && CwHigh > 0
|
||||
? new BandSegments(
|
||||
band,
|
||||
Frequency.FromKilohertz(CwHigh),
|
||||
Frequency.FromKilohertz(DigitalLow),
|
||||
Frequency.FromKilohertz(DigitalHigh))
|
||||
: null;
|
||||
|
||||
public static StoredSubBand From(BandSegments segments) => new()
|
||||
{
|
||||
Band = segments.Band.Name,
|
||||
CwHigh = segments.CwHigh.Kilohertz,
|
||||
DigitalLow = segments.DigitalLow.Kilohertz,
|
||||
DigitalHigh = segments.DigitalHigh.Kilohertz,
|
||||
};
|
||||
}
|
||||
|
||||
/// One radio's `rigctld`.
|
||||
public sealed record StoredRadio
|
||||
{
|
||||
|
||||
20
src/Nonemm.App/Dialogs/SubBandsDialog.axaml
Normal file
20
src/Nonemm.App/Dialogs/SubBandsDialog.axaml
Normal file
@@ -0,0 +1,20 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.SubBandsDialog"
|
||||
Title="Sub bands" Width="460" Height="460"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<DockPanel Margin="14">
|
||||
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" FontSize="11" Opacity="0.7"
|
||||
Margin="0,0,0,10"
|
||||
Text="Kilohertz. CW runs from the bottom of the band to the CW end, phone from there to the top. Leave the digital pair empty for no digital block. Blank the CW end to go back to the default." />
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Spacing="6" Margin="0,10,0,0">
|
||||
<Button Content="Defaults" Click="OnDefaults" />
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<ScrollViewer>
|
||||
<Grid Name="Table" ColumnDefinitions="60,*,*,*" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
125
src/Nonemm.App/Dialogs/SubBandsDialog.axaml.cs
Normal file
125
src/Nonemm.App/Dialogs/SubBandsDialog.axaml.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// The CW, digital and phone boundaries the bandmap paints, one row per band.
|
||||
/// N1MM has the same editor under Config; the numbers are a drawing aid, so a
|
||||
/// row that cannot be read is dropped rather than refused.
|
||||
public sealed partial class SubBandsDialog : Window
|
||||
{
|
||||
private readonly Settings settings;
|
||||
private readonly List<Row> rows = [];
|
||||
|
||||
public SubBandsDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
Build(settings.ToBandPlan());
|
||||
}
|
||||
|
||||
private void Build(BandPlan plan)
|
||||
{
|
||||
Table.Children.Clear();
|
||||
Table.RowDefinitions.Clear();
|
||||
rows.Clear();
|
||||
AddHeader();
|
||||
int at = 1;
|
||||
foreach (BandSegments segments in BandPlan.Default.All)
|
||||
{
|
||||
BandSegments shown = plan.For(segments.Band) ?? segments;
|
||||
rows.Add(AddRow(at++, shown));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddHeader()
|
||||
{
|
||||
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
string[] headings = ["Band", "CW ends", "Digital from", "Digital to"];
|
||||
for (int column = 0; column < headings.Length; column++)
|
||||
{
|
||||
TextBlock text = new()
|
||||
{
|
||||
Text = headings[column],
|
||||
FontSize = 11,
|
||||
Opacity = 0.7,
|
||||
Margin = new Avalonia.Thickness(0, 0, 6, 4),
|
||||
};
|
||||
Grid.SetColumn(text, column);
|
||||
Table.Children.Add(text);
|
||||
}
|
||||
}
|
||||
|
||||
private Row AddRow(int at, BandSegments segments)
|
||||
{
|
||||
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
TextBlock name = new()
|
||||
{
|
||||
Text = segments.Band.Name,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
Margin = new Avalonia.Thickness(0, 2, 6, 2),
|
||||
};
|
||||
Grid.SetRow(name, at);
|
||||
Table.Children.Add(name);
|
||||
|
||||
TextBox cw = Box(at, 1, segments.CwHigh);
|
||||
TextBox digitalLow = Box(at, 2, segments.HasDigital ? segments.DigitalLow : Frequency.Zero);
|
||||
TextBox digitalHigh = Box(at, 3, segments.HasDigital ? segments.DigitalHigh : Frequency.Zero);
|
||||
return new Row(segments.Band, cw, digitalLow, digitalHigh);
|
||||
}
|
||||
|
||||
private TextBox Box(int at, int column, Frequency value)
|
||||
{
|
||||
TextBox box = new()
|
||||
{
|
||||
Text = value.Hertz == 0 ? "" : value.Kilohertz.ToString("0.#", CultureInfo.InvariantCulture),
|
||||
Margin = new Avalonia.Thickness(0, 2, 6, 2),
|
||||
};
|
||||
Grid.SetRow(box, at);
|
||||
Grid.SetColumn(box, column);
|
||||
Table.Children.Add(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
private void OnDefaults(object? sender, RoutedEventArgs e) => Build(BandPlan.Default);
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||
{
|
||||
SubBands = rows.Select(r => r.ToStored()).OfType<StoredSubBand>().ToList(),
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
|
||||
private sealed record Row(Band Band, TextBox CwHigh, TextBox DigitalLow, TextBox DigitalHigh)
|
||||
{
|
||||
/// Null when the row is the default for that band, so a plan nobody has
|
||||
/// touched stays out of the settings file.
|
||||
public StoredSubBand? ToStored()
|
||||
{
|
||||
BandSegments? mine = Segments();
|
||||
return mine is null || mine == BandPlan.Default.For(Band)
|
||||
? null
|
||||
: StoredSubBand.From(mine);
|
||||
}
|
||||
|
||||
private BandSegments? Segments()
|
||||
{
|
||||
if (Kilohertz(CwHigh) is not { } cw)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Frequency low = Kilohertz(DigitalLow) ?? Frequency.Zero;
|
||||
Frequency high = Kilohertz(DigitalHigh) ?? Frequency.Zero;
|
||||
return new BandSegments(Band, cw, low, high);
|
||||
}
|
||||
|
||||
private static Frequency? Kilohertz(TextBox box) =>
|
||||
double.TryParse(box.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
|
||||
&& value > 0
|
||||
? Frequency.FromKilohertz(value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ public sealed class BandmapView : Control
|
||||
private static readonly IBrush TransmitBrush = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
|
||||
private static readonly IBrush SecondBrush = new SolidColorBrush(Color.FromRgb(0xE6, 0x7E, 0x22));
|
||||
|
||||
private static readonly IBrush CwBrush = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9), 0.10);
|
||||
private static readonly IBrush PhoneBrush = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60), 0.10);
|
||||
private static readonly IBrush DigitalBrush = new SolidColorBrush(Color.FromRgb(0x8E, 0x44, 0xAD), 0.13);
|
||||
|
||||
/// Past the band edge. The slice can reach there when the radio does.
|
||||
private static readonly IBrush OutsideBrush = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B), 0.13);
|
||||
|
||||
private readonly Typeface typeface = new(new FontFamily("monospace"));
|
||||
private IReadOnlyList<BandmapLabel> placed = [];
|
||||
private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1);
|
||||
@@ -42,6 +49,10 @@ public sealed class BandmapView : Control
|
||||
|
||||
public Frequency High { get; set; } = Bands.Band20M.High;
|
||||
|
||||
/// The CW and phone parts of the band, painted behind the scale. Null for a
|
||||
/// band with no boundary in the band plan, and then nothing is painted.
|
||||
public BandSegments? Segments { get; set; }
|
||||
|
||||
/// How the callsign is coloured: the same scorer as the entry window.
|
||||
public Func<Spot, IBrush> ColourOf { get; set; } = _ => Brushes.Gray;
|
||||
|
||||
@@ -58,6 +69,7 @@ public sealed class BandmapView : Control
|
||||
scale = new BandmapScale(Low, High, Math.Max(1, Bounds.Height - (2 * Inset)));
|
||||
IBrush ink = Foreground();
|
||||
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
||||
DrawSegments(context);
|
||||
DrawScale(context, ink);
|
||||
DrawVfos(context);
|
||||
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
|
||||
@@ -68,6 +80,39 @@ public sealed class BandmapView : Control
|
||||
}
|
||||
}
|
||||
|
||||
/// N1MM paints the CW, digital and phone parts of the band behind the
|
||||
/// scale, the digital block over the other two.
|
||||
/// Anything past a band edge is painted as out of band, which is the only
|
||||
/// place the edges themselves show.
|
||||
private void DrawSegments(DrawingContext context)
|
||||
{
|
||||
if (Segments is not { } segments)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Fill(context, segments.CwLow, segments.CwHigh, CwBrush);
|
||||
if (segments.HasPhone)
|
||||
{
|
||||
Fill(context, segments.PhoneLow, segments.PhoneHigh, PhoneBrush);
|
||||
}
|
||||
if (segments.HasDigital)
|
||||
{
|
||||
Fill(context, segments.DigitalLow, segments.DigitalHigh, DigitalBrush);
|
||||
}
|
||||
Fill(context, Low, segments.CwLow, OutsideBrush);
|
||||
Fill(context, segments.PhoneHigh, High, OutsideBrush);
|
||||
}
|
||||
|
||||
private void Fill(DrawingContext context, Frequency from, Frequency to, IBrush brush)
|
||||
{
|
||||
double top = Math.Max(0, scale.YFor(from)) + Inset;
|
||||
double bottom = Math.Min(scale.Height, scale.YFor(to)) + Inset;
|
||||
if (bottom > top)
|
||||
{
|
||||
context.FillRectangle(brush, new Rect(0, top, Bounds.Width, bottom - top));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawScale(DrawingContext context, IBrush ink)
|
||||
{
|
||||
Pen line = new(Opaque(ink, 0.5), 1);
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
{
|
||||
View.Spots = [];
|
||||
View.Vfos = [];
|
||||
View.Segments = null;
|
||||
StatusText.Text = session.Position is null ? "no contest is open" : "off band";
|
||||
View.InvalidateVisual();
|
||||
return;
|
||||
@@ -60,10 +61,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
View.TickStep = StepFor(high.Hertz - low.Hertz);
|
||||
View.Spots = session.Bandmap.On(band);
|
||||
View.Vfos = Vfos();
|
||||
StatusText.Text = $"{View.Spots.Count} stations · {low.Kilohertz:0.#} to {high.Kilohertz:0.#} kHz";
|
||||
View.Segments = session.BandPlan.For(band);
|
||||
StatusText.Text =
|
||||
$"{View.Spots.Count} stations · {low.Kilohertz:0.#} to {high.Kilohertz:0.#} kHz{Split(band)}";
|
||||
View.InvalidateVisual();
|
||||
}
|
||||
|
||||
/// The band plan boundary, so the two shades behind the scale have a name.
|
||||
private string Split(Band band) =>
|
||||
session.BandPlan.For(band) is { HasPhone: true } segments
|
||||
? $" · CW/phone {segments.CwHigh.Kilohertz:0.#}"
|
||||
: "";
|
||||
|
||||
/// Green for where the operator is listening, red for where the radio
|
||||
/// transmits when working split, orange for the other radio of a two-radio
|
||||
/// station.
|
||||
|
||||
@@ -353,6 +353,24 @@ public sealed partial class EntryWindow
|
||||
: $"{Path.GetFileName(path)} holds no calls we could read");
|
||||
}
|
||||
|
||||
/// The bandmap's CW, digital and phone boundaries.
|
||||
private async void OnSubBandSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SubBandsDialog dialog = new(session.Settings);
|
||||
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
||||
if (updated is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(updated);
|
||||
Status(updated.SubBands.Count switch
|
||||
{
|
||||
0 => "sub bands back to the defaults",
|
||||
1 => "1 band changed from the defaults",
|
||||
int changed => $"{changed} bands changed from the defaults",
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
|
||||
await Download(
|
||||
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<MenuItem Header="_Keyer and messages…" Click="OnKeyerSettings" />
|
||||
<Separator />
|
||||
<MenuItem Header="Call _History File…" Click="OnCallHistoryFile" />
|
||||
<MenuItem Header="S_ub bands…" Click="OnSubBandSettings" />
|
||||
<Separator />
|
||||
<MenuItem Header="Download Country _File" Click="OnDownloadCountryFile" />
|
||||
<MenuItem Header="Download Check _Partial File" Click="OnDownloadCallDatabase" />
|
||||
|
||||
75
src/Nonemm.Core/BandPlan.cs
Normal file
75
src/Nonemm.Core/BandPlan.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// Where the CW, digital and phone parts of one band are. The digital part is
|
||||
/// empty until somebody fills it in, which is how N1MM ships as well: its
|
||||
/// `DigiBands` table is all zeros and its CW and SSB tables meet at one
|
||||
/// boundary.
|
||||
public sealed record BandSegments(
|
||||
Band Band,
|
||||
Frequency CwHigh,
|
||||
Frequency DigitalLow = default,
|
||||
Frequency DigitalHigh = default)
|
||||
{
|
||||
public Frequency CwLow => Band.Low;
|
||||
|
||||
public Frequency PhoneLow => CwHigh;
|
||||
|
||||
public Frequency PhoneHigh => Band.High;
|
||||
|
||||
/// False on 30M, which has no phone allocation.
|
||||
public bool HasPhone => Band.High > CwHigh;
|
||||
|
||||
public bool HasDigital => DigitalHigh > DigitalLow;
|
||||
}
|
||||
|
||||
/// The boundaries the bandmap paints. N1MM holds the same numbers in its
|
||||
/// `CWBands`, `DigiBands` and `SSBBands` tables and lets the operator edit
|
||||
/// them; so does this, through Config ▸ Sub bands.
|
||||
///
|
||||
/// The defaults are one worldwide set, as N1MM's are, not one per ITU region.
|
||||
/// They are a drawing aid, not a licence: nothing stops a contact being logged
|
||||
/// either side of a boundary. Bands where N1MM's own numbers contradict
|
||||
/// themselves — 60M, and everything above 2M — have no entry, and nothing is
|
||||
/// painted for them.
|
||||
public sealed class BandPlan
|
||||
{
|
||||
private static readonly Dictionary<string, double> DefaultCwEndsAtKilohertz = new()
|
||||
{
|
||||
["160M"] = 1_840,
|
||||
["80M"] = 3_600,
|
||||
["40M"] = 7_125,
|
||||
["30M"] = 10_150,
|
||||
["20M"] = 14_150,
|
||||
["17M"] = 18_110,
|
||||
["15M"] = 21_200,
|
||||
["12M"] = 24_930,
|
||||
["10M"] = 28_300,
|
||||
["6M"] = 50_100,
|
||||
["4M"] = 70_100,
|
||||
["2M"] = 144_100,
|
||||
};
|
||||
|
||||
public static readonly BandPlan Default = new(DefaultCwEndsAtKilohertz
|
||||
.Select(pair => new BandSegments(
|
||||
Bands.Named(pair.Key)!,
|
||||
Frequency.FromKilohertz(pair.Value))));
|
||||
|
||||
private readonly Dictionary<string, BandSegments> byBand;
|
||||
|
||||
public BandPlan(IEnumerable<BandSegments> segments)
|
||||
{
|
||||
byBand = segments.ToDictionary(s => s.Band.Name, StringComparer.OrdinalIgnoreCase);
|
||||
All = [.. byBand.Values.OrderBy(s => s.Band.Low.Hertz)];
|
||||
}
|
||||
|
||||
/// Low band first, which is the order the editor lists them in.
|
||||
public IReadOnlyList<BandSegments> All { get; }
|
||||
|
||||
public BandSegments? For(Band band) =>
|
||||
byBand.TryGetValue(band.Name, out BandSegments? segments) ? segments : null;
|
||||
|
||||
/// The same plan with one band's boundaries changed. A band the plan does
|
||||
/// not hold is added.
|
||||
public BandPlan With(BandSegments segments) =>
|
||||
new(All.Where(s => s.Band != segments.Band).Append(segments));
|
||||
}
|
||||
61
tests/Nonemm.Core.Tests/BandPlanTests.cs
Normal file
61
tests/Nonemm.Core.Tests/BandPlanTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Core.Tests;
|
||||
|
||||
public class BandPlanTests
|
||||
{
|
||||
[Fact]
|
||||
public void TwentyMetresSplitsAtTheCwEdge()
|
||||
{
|
||||
BandSegments segments = Assert.IsType<BandSegments>(BandPlan.Default.For(Bands.Band20M));
|
||||
|
||||
Assert.Equal(Bands.Band20M.Low, segments.CwLow);
|
||||
Assert.Equal(Frequency.FromKilohertz(14_150), segments.CwHigh);
|
||||
Assert.Equal(Frequency.FromKilohertz(14_150), segments.PhoneLow);
|
||||
Assert.Equal(Bands.Band20M.High, segments.PhoneHigh);
|
||||
Assert.True(segments.HasPhone);
|
||||
Assert.False(segments.HasDigital);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThirtyMetresIsAllCw()
|
||||
{
|
||||
BandSegments segments = Assert.IsType<BandSegments>(BandPlan.Default.For(Bands.Band30M));
|
||||
|
||||
Assert.Equal(Bands.Band30M.High, segments.CwHigh);
|
||||
Assert.False(segments.HasPhone);
|
||||
}
|
||||
|
||||
/// Nothing is painted for a band with no boundary.
|
||||
[Theory]
|
||||
[InlineData("60M")]
|
||||
[InlineData("70CM")]
|
||||
[InlineData("630M")]
|
||||
public void ABandWithNoBoundaryHasNoSegments(string name) =>
|
||||
Assert.Null(BandPlan.Default.For(Bands.Named(name)!));
|
||||
|
||||
[Fact]
|
||||
public void ChangingOneBandLeavesTheOthersAlone()
|
||||
{
|
||||
BandPlan changed = BandPlan.Default.With(
|
||||
new BandSegments(Bands.Band20M, Frequency.FromKilohertz(14_100)));
|
||||
|
||||
Assert.Equal(Frequency.FromKilohertz(14_100), changed.For(Bands.Band20M)!.CwHigh);
|
||||
Assert.Equal(
|
||||
BandPlan.Default.For(Bands.Band40M)!.CwHigh,
|
||||
changed.For(Bands.Band40M)!.CwHigh);
|
||||
Assert.Equal(BandPlan.Default.All.Count, changed.All.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADigitalBlockIsThereOnceItHasWidth()
|
||||
{
|
||||
BandSegments segments = new(
|
||||
Bands.Band20M,
|
||||
Frequency.FromKilohertz(14_150),
|
||||
Frequency.FromKilohertz(14_070),
|
||||
Frequency.FromKilohertz(14_099));
|
||||
|
||||
Assert.True(segments.HasDigital);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user