diff --git a/README.md b/README.md index dbd655c..9ed426c 100644 --- a/README.md +++ b/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 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 Double-click a cell in the log window to change it. The columns follow the diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index a5734a2..0af9476 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -105,6 +105,9 @@ + + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 36a8893..e81a279 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -409,6 +409,8 @@ public sealed partial class EntryWindow : Window RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80)); ContestText.Text = ContestLine(); + PathText.Text = PathLine(); + Verdict? verdict = Logging.Verdict(); VerdictBorder.Background = Verdicts.Colour(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}"; } + /// 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) { if (Logging is null || Logging.Entry.Call.Trim().Length == 0) diff --git a/src/Nonemm.Core/SunTimes.cs b/src/Nonemm.Core/SunTimes.cs new file mode 100644 index 0000000..b71bf3e --- /dev/null +++ b/src/Nonemm.Core/SunTimes.cs @@ -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; +} diff --git a/src/Nonemm.Session/StationPath.cs b/src/Nonemm.Session/StationPath.cs new file mode 100644 index 0000000..2c8a517 --- /dev/null +++ b/src/Nonemm.Session/StationPath.cs @@ -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); +} diff --git a/tests/Nonemm.Core.Tests/SunTimesTests.cs b/tests/Nonemm.Core.Tests/SunTimesTests.cs new file mode 100644 index 0000000..4b3beac --- /dev/null +++ b/tests/Nonemm.Core.Tests/SunTimesTests.cs @@ -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))); +} diff --git a/tests/Nonemm.Session.Tests/StationPathTests.cs b/tests/Nonemm.Session.Tests/StationPathTests.cs new file mode 100644 index 0000000..87ba1f0 --- /dev/null +++ b/tests/Nonemm.Session.Tests/StationPathTests.cs @@ -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.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.For(known, Now)); + StationPath fromCountry = Assert.IsType(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.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(path.Sunrise).Hour); + Assert.Equal(9, Assert.IsType(path.Sunset).Hour); + } +}