Say where the station being worked is
N1MM writes a line under its entry window with the beam heading, the heading the long way round, the distance and the sun times at the other end. This writes the same line. SunTimes is the published sunrise equation, with the usual -0.833 degrees for the sun's radius and refraction at the horizon, and it returns nothing on the days inside the polar circles when the sun does not rise or set. It agrees with published times for London at both solstices, Sydney in June and the equator at the equinox, to within three minutes. StationPath picks the position: the grid square when the contest exchanges one and it has been copied, then the call history file, then the country file, which puts the station in the middle of its country. That is close enough to point a beam with. Our own end comes from the station grid square, or from the country file for our own call. One thing to know when reading the times: they are that station's own sunrise and sunset in UTC, so for Japan the sun rises late the evening before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
12
README.md
12
README.md
@@ -173,6 +173,18 @@ in again while you are running, and `EsmSendsCorrectedCall` sends the call again
|
|||||||
in front of the last message when you have corrected it — copy `SM3AB`, fix it
|
in front of the last message when you have corrected it — copy `SM3AB`, fix it
|
||||||
to `SM3ABC`, and the key sends `SM3ABC TU DL1ABC`.
|
to `SM3ABC`, and the key sends `SM3ABC TU DL1ABC`.
|
||||||
|
|
||||||
|
### Where the other station is
|
||||||
|
|
||||||
|
Under the entry window is the line N1MM writes there: the beam heading, the
|
||||||
|
heading the long way round, the distance in kilometres, and the sunrise and
|
||||||
|
sunset times at the other station.
|
||||||
|
|
||||||
|
The position comes from the grid square when the contest exchanges one and it
|
||||||
|
has been copied, or from the call history file, and from the country file
|
||||||
|
otherwise, which puts the station in the middle of its country. Sunrise and
|
||||||
|
sunset are worked out from that position for today; inside the polar circles the
|
||||||
|
line says the sun does not rise or set.
|
||||||
|
|
||||||
### Editing the log
|
### Editing the log
|
||||||
|
|
||||||
Double-click a cell in the log window to change it. The columns follow the
|
Double-click a cell in the log window to change it. The columns follow the
|
||||||
|
|||||||
@@ -105,6 +105,9 @@
|
|||||||
<TextBlock Name="VerdictText" FontSize="12" Text="" />
|
<TextBlock Name="VerdictText" FontSize="12" Text="" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Name="PathText" FontSize="11" Opacity="0.75" Margin="2,4,0,0" Text=""
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" Margin="2,4,0,0" Text="" />
|
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" Margin="2,4,0,0" Text="" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ public sealed partial class EntryWindow : Window
|
|||||||
RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
|
RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
|
||||||
ContestText.Text = ContestLine();
|
ContestText.Text = ContestLine();
|
||||||
|
|
||||||
|
PathText.Text = PathLine();
|
||||||
|
|
||||||
Verdict? verdict = Logging.Verdict();
|
Verdict? verdict = Logging.Verdict();
|
||||||
VerdictBorder.Background = Verdicts.Colour(verdict);
|
VerdictBorder.Background = Verdicts.Colour(verdict);
|
||||||
VerdictText.Text = VerdictLine(verdict);
|
VerdictText.Text = VerdictLine(verdict);
|
||||||
@@ -434,6 +436,21 @@ public sealed partial class EntryWindow : Window
|
|||||||
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}{radio}";
|
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}{radio}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the station being worked is: the beam heading, the heading the
|
||||||
|
/// long way round, the distance, and the sun there. N1MM writes the same
|
||||||
|
/// line under its entry window.
|
||||||
|
private string PathLine()
|
||||||
|
{
|
||||||
|
if (Logging is null || StationPath.For(Logging, DateTime.UtcNow) is not { } path)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
string sun = path.Sunrise is { } rise && path.Sunset is { } set
|
||||||
|
? $" · SR {rise:HH:mm}Z SS {set:HH:mm}Z"
|
||||||
|
: " · no sunrise or sunset today";
|
||||||
|
return $"Hdg {path.Heading:0}° · LP {path.LongPath:0}° · {path.DistanceKm:N0} km{sun}";
|
||||||
|
}
|
||||||
|
|
||||||
private string VerdictLine(Verdict? verdict)
|
private string VerdictLine(Verdict? verdict)
|
||||||
{
|
{
|
||||||
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
|
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
|
||||||
|
|||||||
53
src/Nonemm.Core/SunTimes.cs
Normal file
53
src/Nonemm.Core/SunTimes.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Nonemm.Core;
|
||||||
|
|
||||||
|
/// Sunrise and sunset for a place, in UTC. Contest operators watch them: a band
|
||||||
|
/// opens along the grey line, and knowing when the sun rises over the station
|
||||||
|
/// being called says whether to keep calling.
|
||||||
|
///
|
||||||
|
/// The equations are the published sunrise equation, with the standard
|
||||||
|
/// −0.833° for the sun's radius and refraction at the horizon. Accurate to
|
||||||
|
/// about a minute, which is as much as anyone reads off the screen.
|
||||||
|
public static class SunTimes
|
||||||
|
{
|
||||||
|
private const double Zenith = -0.833;
|
||||||
|
private const double Obliquity = 23.4397;
|
||||||
|
private const double JulianDay2000 = 2451545.0;
|
||||||
|
|
||||||
|
/// Null when the sun does not rise or set that day, which is what happens
|
||||||
|
/// inside the polar circles.
|
||||||
|
public static (DateTime Rise, DateTime Set)? For(double latitude, double longitude, DateTime dayUtc)
|
||||||
|
{
|
||||||
|
// the day number the equation wants counts from noon, not midnight
|
||||||
|
double day = Math.Ceiling(JulianDayOf(dayUtc) - JulianDay2000 + 0.0008);
|
||||||
|
double noon = day - (longitude / 360.0);
|
||||||
|
double anomaly = Radians((357.5291 + (0.98560028 * noon)) % 360.0);
|
||||||
|
double centre = (1.9148 * Math.Sin(anomaly))
|
||||||
|
+ (0.02 * Math.Sin(2 * anomaly))
|
||||||
|
+ (0.0003 * Math.Sin(3 * anomaly));
|
||||||
|
double longitudeOfSun = Radians((Degrees(anomaly) + centre + 282.9372) % 360.0);
|
||||||
|
double transit = JulianDay2000 + noon
|
||||||
|
+ (0.0053 * Math.Sin(anomaly))
|
||||||
|
- (0.0069 * Math.Sin(2 * longitudeOfSun));
|
||||||
|
double declination = Math.Asin(Math.Sin(longitudeOfSun) * Math.Sin(Radians(Obliquity)));
|
||||||
|
|
||||||
|
double hourAngle = (Math.Sin(Radians(Zenith)) - (Math.Sin(Radians(latitude)) * Math.Sin(declination)))
|
||||||
|
/ (Math.Cos(Radians(latitude)) * Math.Cos(declination));
|
||||||
|
if (hourAngle is < -1 or > 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
double half = Degrees(Math.Acos(hourAngle)) / 360.0;
|
||||||
|
return (DateOf(transit - half), DateOf(transit + half));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double JulianDayOf(DateTime utc) =>
|
||||||
|
(utc.Date - new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)).TotalDays + JulianDay2000;
|
||||||
|
|
||||||
|
private static DateTime DateOf(double julianDay) =>
|
||||||
|
new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
.AddDays(julianDay - JulianDay2000);
|
||||||
|
|
||||||
|
private static double Radians(double degrees) => degrees * Math.PI / 180.0;
|
||||||
|
|
||||||
|
private static double Degrees(double radians) => radians * 180.0 / Math.PI;
|
||||||
|
}
|
||||||
59
src/Nonemm.Session/StationPath.cs
Normal file
59
src/Nonemm.Session/StationPath.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Core.Country;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// Where the station being worked is from here: the beam heading, the heading
|
||||||
|
/// the long way round, how far away it is, and when the sun rises and sets
|
||||||
|
/// there. N1MM writes the same line under its entry window.
|
||||||
|
///
|
||||||
|
/// The position comes from the grid square if the contest exchanges one and the
|
||||||
|
/// operator has copied it, and from the country file otherwise, which puts the
|
||||||
|
/// station in the middle of its country. That is close enough to point a beam
|
||||||
|
/// with, and it is what N1MM does.
|
||||||
|
public sealed record StationPath(
|
||||||
|
double Heading,
|
||||||
|
double DistanceKm,
|
||||||
|
DateTime? Sunrise,
|
||||||
|
DateTime? Sunset)
|
||||||
|
{
|
||||||
|
/// The heading the long way round, which a big antenna is sometimes better
|
||||||
|
/// off using.
|
||||||
|
public double LongPath => (Heading + 180.0) % 360.0;
|
||||||
|
|
||||||
|
/// Null when there is nothing to work out a path from: no station in the
|
||||||
|
/// callsign box, no country file, or no position for either end.
|
||||||
|
public static StationPath? For(RadioPosition session, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (Mine(session) is not { } here || Theirs(session) is not { } there)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
(DateTime Rise, DateTime Set)? sun = SunTimes.For(there.Latitude, there.Longitude, nowUtc);
|
||||||
|
return new StationPath(
|
||||||
|
GridSquare.Bearing(here.Latitude, here.Longitude, there.Latitude, there.Longitude),
|
||||||
|
GridSquare.DistanceKm(here.Latitude, here.Longitude, there.Latitude, there.Longitude),
|
||||||
|
sun?.Rise,
|
||||||
|
sun?.Set);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double Latitude, double Longitude)? Mine(RadioPosition session) =>
|
||||||
|
Place(session.Me.GridSquare) ?? Place(session.Session.Countries?.Find(session.Me.Callsign));
|
||||||
|
|
||||||
|
private static (double Latitude, double Longitude)? Theirs(RadioPosition session)
|
||||||
|
{
|
||||||
|
if (session.Entry.Call.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Place(session.Entry.ValueOf(Contests.ExchangeSlot.GridSquare))
|
||||||
|
?? Place(session.Session.History.Find(session.Entry.Call)?.GridSquare ?? "")
|
||||||
|
?? Place(session.Country());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double Latitude, double Longitude)? Place(string grid) =>
|
||||||
|
GridSquare.TryParse(grid, out GridSquare square) ? (square.Latitude, square.Longitude) : null;
|
||||||
|
|
||||||
|
private static (double Latitude, double Longitude)? Place(CountryLookup? country) =>
|
||||||
|
country is null ? null : (country.Latitude, country.Longitude);
|
||||||
|
}
|
||||||
57
tests/Nonemm.Core.Tests/SunTimesTests.cs
Normal file
57
tests/Nonemm.Core.Tests/SunTimesTests.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
namespace Nonemm.Core.Tests;
|
||||||
|
|
||||||
|
public class SunTimesTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CloseEnough = TimeSpan.FromMinutes(3);
|
||||||
|
|
||||||
|
private static void Near(string expected, DateTime actual) =>
|
||||||
|
Assert.True(
|
||||||
|
(DateTime.Parse(expected, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.AdjustToUniversal
|
||||||
|
| System.Globalization.DateTimeStyles.AssumeUniversal) - actual).Duration() < CloseEnough,
|
||||||
|
$"expected about {expected}, got {actual:yyyy-MM-dd HH:mm}Z");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MidsummerInLondon()
|
||||||
|
{
|
||||||
|
(DateTime rise, DateTime set) = Assert.IsType<(DateTime, DateTime)>(
|
||||||
|
SunTimes.For(51.48, -0.13, new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc)));
|
||||||
|
|
||||||
|
Near("2026-06-21T03:43Z", rise);
|
||||||
|
Near("2026-06-21T20:21Z", set);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MidwinterInLondon()
|
||||||
|
{
|
||||||
|
(DateTime rise, DateTime set) = Assert.IsType<(DateTime, DateTime)>(
|
||||||
|
SunTimes.For(51.48, -0.13, new DateTime(2026, 12, 21, 0, 0, 0, DateTimeKind.Utc)));
|
||||||
|
|
||||||
|
Near("2026-12-21T08:04Z", rise);
|
||||||
|
Near("2026-12-21T15:53Z", set);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheEquinoxIsAboutSixToSixEverywhereOnTheEquator()
|
||||||
|
{
|
||||||
|
(DateTime rise, DateTime set) = Assert.IsType<(DateTime, DateTime)>(
|
||||||
|
SunTimes.For(0, 0, new DateTime(2026, 3, 20, 0, 0, 0, DateTimeKind.Utc)));
|
||||||
|
|
||||||
|
Near("2026-03-20T06:04Z", rise);
|
||||||
|
Near("2026-03-20T18:10Z", set);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SouthOfTheEquatorTheSeasonsAreTheOtherWayRound()
|
||||||
|
{
|
||||||
|
(DateTime rise, DateTime set) = Assert.IsType<(DateTime, DateTime)>(
|
||||||
|
SunTimes.For(-33.87, 151.21, new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc)));
|
||||||
|
|
||||||
|
Near("2026-06-20T21:00Z", rise);
|
||||||
|
Near("2026-06-21T06:54Z", set);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InsideTheArcticCircleTheSunDoesNotSetInJune() =>
|
||||||
|
Assert.Null(SunTimes.For(78.22, 15.65, new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc)));
|
||||||
|
}
|
||||||
89
tests/Nonemm.Session.Tests/StationPathTests.cs
Normal file
89
tests/Nonemm.Session.Tests/StationPathTests.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Nonemm.Contests;
|
||||||
|
using Nonemm.Contests.Rules;
|
||||||
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Core.Calls;
|
||||||
|
using Nonemm.Core.Country;
|
||||||
|
using Nonemm.Storage;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
public class StationPathTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// Two entities far enough apart to check a heading against a map.
|
||||||
|
private static readonly CountryFile Countries = CountryFile.Parse(
|
||||||
|
"""
|
||||||
|
Slovak Republic: 15: 28: EU: 48.67: -19.50: -1.0: OM:
|
||||||
|
OM;
|
||||||
|
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
|
||||||
|
JA;
|
||||||
|
""");
|
||||||
|
|
||||||
|
private static RadioPosition Session(string myGrid = "", CallHistory? history = null)
|
||||||
|
{
|
||||||
|
FakeLogStore store = new();
|
||||||
|
ContestInstance instance = store.AddContest(new ContestInstance
|
||||||
|
{
|
||||||
|
ContestNumber = 0,
|
||||||
|
ContestName = "CQWW",
|
||||||
|
SentExchange = "15",
|
||||||
|
});
|
||||||
|
return new RadioPosition(new ContestSession(
|
||||||
|
store,
|
||||||
|
new CqWorldWide(ModeCategory.Cw),
|
||||||
|
instance,
|
||||||
|
new StationInfo { Callsign = "OM5M", CqZone = 15, GridSquare = myGrid },
|
||||||
|
Countries,
|
||||||
|
history));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNothingInTheCallsignBoxThereIsNoPath() =>
|
||||||
|
Assert.Null(StationPath.For(Session(), Now));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheCountryFilePlacesAStationWhoseGridIsNotKnown()
|
||||||
|
{
|
||||||
|
RadioPosition session = Session();
|
||||||
|
session.Entry.Call = "JA1XYZ";
|
||||||
|
|
||||||
|
StationPath path = Assert.IsType<StationPath>(StationPath.For(session, Now));
|
||||||
|
|
||||||
|
// Slovakia to Japan is a little north of east, about nine thousand km
|
||||||
|
Assert.InRange(path.Heading, 40, 60);
|
||||||
|
Assert.InRange(path.DistanceKm, 8_500, 9_500);
|
||||||
|
Assert.Equal((path.Heading + 180) % 360, path.LongPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGridFromTheCallHistoryBeatsTheCountryFile()
|
||||||
|
{
|
||||||
|
RadioPosition known = Session(
|
||||||
|
myGrid: "JN88",
|
||||||
|
history: CallHistory.Parse("!!Order!!,Call,LOC1\nJA1XYZ,PM95"));
|
||||||
|
known.Entry.Call = "JA1XYZ";
|
||||||
|
RadioPosition unknown = Session(myGrid: "JN88");
|
||||||
|
unknown.Entry.Call = "JA1XYZ";
|
||||||
|
|
||||||
|
StationPath fromGrid = Assert.IsType<StationPath>(StationPath.For(known, Now));
|
||||||
|
StationPath fromCountry = Assert.IsType<StationPath>(StationPath.For(unknown, Now));
|
||||||
|
|
||||||
|
Assert.NotEqual(Math.Round(fromGrid.DistanceKm), Math.Round(fromCountry.DistanceKm));
|
||||||
|
Assert.InRange(fromGrid.DistanceKm, 9_000, 9_600);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheSunTimesAreTheOtherStationsOwn()
|
||||||
|
{
|
||||||
|
RadioPosition session = Session();
|
||||||
|
session.Entry.Call = "JA1XYZ";
|
||||||
|
|
||||||
|
StationPath path = Assert.IsType<StationPath>(StationPath.For(session, Now));
|
||||||
|
|
||||||
|
// Japan is nine hours ahead, so the sun rises there late the evening
|
||||||
|
// before, in UTC, and sets in the morning
|
||||||
|
Assert.Equal(20, Assert.IsType<DateTime>(path.Sunrise).Hour);
|
||||||
|
Assert.Equal(9, Assert.IsType<DateTime>(path.Sunset).Hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user