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);
+ }
+}