From 77bb79877206e9d4186f5115f15b6829ff684b70 Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Thu, 22 Oct 2026 22:03:32 -0400 Subject: [PATCH 1/3] Keep polar sunrises and sunsets near the date line on their own date getInstantFromTime receives the event's UTC time folded into [0, 24) and picks its date from the local mean time. It moved a sunrise after 18:00 back a day and a sunset before 06:00 forward a day, but not the other way. Just west of the date line, a sunrise near local noon has a UTC time just past 24:00. It folds to just past 00:00 and was placed a day early. East of the date line a sunset near local noon could fold the other way and land a day late. This happens in polar winter and summer, when sunrise and sunset are close to noon. A sunrise at -6 hours local mean time or earlier now moves forward a day, and a sunset at +30 hours or later moves back a day. The date mapping then matches the window each date's sunrise (18:00 the evening before through 18:00) and sunset (06:00 through 06:00 the next morning) belong to. --- .../zmanim/AstronomicalCalendar.java | 7 +++++ .../zmanim/AstronomicalCalendarTest.java | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java b/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java index dddb1178..9e1faad8 100644 --- a/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java +++ b/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java @@ -553,10 +553,17 @@ protected Instant getInstantFromTime(double time, SolarEvent solarEvent) { LocalDate date = getAdjustedLocalDate(); double localTimeHours = (getGeoLocation().getLongitude() / 15) + time; + // A sunrise belongs to the date from 18:00 local mean time the evening before through 18:00, and a sunset from + // 06:00 through 06:00 the next morning. The time is in UTC hours folded into [0, 24), so near the date line an + // event can fold either way out of its window. if (solarEvent == SolarEvent.SUNRISE && localTimeHours > 18) { date = date.minusDays(1); + } else if (solarEvent == SolarEvent.SUNRISE && localTimeHours <= -6) { + date = date.plusDays(1); } else if (solarEvent == SolarEvent.SUNSET && localTimeHours < 6) { date = date.plusDays(1); + } else if (solarEvent == SolarEvent.SUNSET && localTimeHours >= 30) { + date = date.minusDays(1); } else if (solarEvent == SolarEvent.MIDNIGHT && localTimeHours < 12) { date = date.plusDays(1); } else if (solarEvent == SolarEvent.NOON) { diff --git a/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java b/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java index 29bcafc1..9dd09b80 100644 --- a/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java +++ b/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java @@ -17,13 +17,21 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; import org.junit.Test; +import com.kosherjava.zmanim.util.AstronomicalCalculator; +import com.kosherjava.zmanim.util.GeoLocation; +import com.kosherjava.zmanim.util.MeeusCalculator; +import com.kosherjava.zmanim.util.NOAACalculator; +import com.kosherjava.zmanim.util.SPACalculator; import com.kosherjava.zmanim.util.SunTimesCalculator; /** @@ -118,4 +126,24 @@ public void swappingCalculatorChangesResult() { assertNotEquals(noaaSunset, sunTimesSunset); } + + /** + * Near the date line a sunrise close to local noon has a UTC time just past 24:00. It must still fall on its own + * date: Cape Schmidt, Chukotka, on 10 January 2021, where the sun is up for about ten minutes around noon. + */ + @Test + public void polarSunriseNearTheDateLineIsOnItsOwnDate() { + ZoneId anadyr = ZoneId.of("Asia/Anadyr"); + LocalDate date = LocalDate.of(2021, 1, 10); + AstronomicalCalculator[] calculators = { new NOAACalculator(), new MeeusCalculator(), new SPACalculator() }; + for (AstronomicalCalculator calculator : calculators) { + AstronomicalCalendar calendar = new AstronomicalCalendar(new GeoLocation("Cape Schmidt", 68.88, -179.37, anadyr)); + calendar.setLocalDate(date); + calendar.setAstronomicalCalculator(calculator); + ZonedDateTime sunrise = calendar.getSeaLevelSunrise().atZone(anadyr); + ZonedDateTime sunset = calendar.getSeaLevelSunset().atZone(anadyr); + assertEquals(calculator.getCalculatorName(), date, sunrise.toLocalDate()); + assertTrue(calculator.getCalculatorName(), sunrise.isBefore(sunset)); + } + } } From 259fbd23709de9531b5bd81491c1423c99df4f6d Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Thu, 22 Oct 2026 22:10:07 -0400 Subject: [PATCH 2/3] Test each date adjustment in getInstantFromTime One test per branch, each with a real place and date that takes it: a sunrise after 18:00 local mean time, a sunset before 06:00 or at +30 hours or later, solar midnight before noon, and solar noon before 0 or after 24 hours local mean time. The existing date-line sunrise test covers a sunrise at -6 hours or earlier. Each test pins the instant to within a second, so a result on the wrong day fails. Disabling any one branch makes its own test fail. --- .../zmanim/AstronomicalCalendarTest.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java b/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java index 9dd09b80..8f1faba6 100644 --- a/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java +++ b/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java @@ -23,7 +23,9 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.util.function.Function; import org.junit.Test; @@ -146,4 +148,64 @@ public void polarSunriseNearTheDateLineIsOnItsOwnDate() { assertTrue(calculator.getCalculatorName(), sunrise.isBefore(sunset)); } } + + /** + * Asserts that {@code event} at the given place, in a zone a whole number of {@code zoneHours} from UTC, comes out + * within a second of {@code expectedMillis}. A result on the wrong day is 24 hours off. + */ + private void assertEventNear(String label, double latitude, double longitude, int zoneHours, LocalDate date, + Function event, long expectedMillis) { + AstronomicalCalendar calendar = new AstronomicalCalendar( + new GeoLocation(label, latitude, longitude, ZoneOffset.ofHours(zoneHours))); + calendar.setLocalDate(date); + Instant actual = event.apply(calendar); + Instant expected = Instant.ofEpochMilli(expectedMillis); + assertTrue(label + ": " + actual + " is not near " + expected, + Duration.between(expected, actual).abs().compareTo(Duration.ofSeconds(1)) < 0); + } + + // One test for each date adjustment in getInstantFromTime. polarSunriseNearTheDateLineIsOnItsOwnDate covers a + // sunrise at -6 hours local mean time or earlier. + + /** A sunrise just after local midnight, after 18:00 local mean time of the previous day, moves back a day. */ + @Test + public void sunriseAfterSixPmLocalMeanTimeMovesBackADay() { + assertEventNear("sunrise after 18:00", -70, 35.235, 2, LocalDate.of(2021, 1, 25), + AstronomicalCalendar::getSeaLevelSunrise, 1611527037358L); // 2021-01-25T00:23:57+02:00 + } + + /** A sunset just before local midnight, before 06:00 local mean time of the date, moves forward a day. */ + @Test + public void sunsetBeforeSixAmLocalMeanTimeMovesForwardADay() { + assertEventNear("sunset before 06:00", -70, -179.37, -12, LocalDate.of(2021, 1, 23), + AstronomicalCalendar::getSeaLevelSunset, 1611488915349L); // 2021-01-23T23:48:35-12:00 + } + + /** A sunset near local noon just east of the date line, at +30 hours local mean time or later, moves back a day. */ + @Test + public void sunsetAfterThirtyHoursLocalMeanTimeMovesBackADay() { + assertEventNear("sunset after 30:00", 68.88, 179, 12, LocalDate.of(2021, 12, 2), + AstronomicalCalendar::getSeaLevelSunset, 1638403159563L); // 2021-12-02T11:59:19+12:00 + } + + /** Solar midnight before noon local mean time moves forward a day. */ + @Test + public void solarMidnightBeforeNoonLocalMeanTimeMovesForwardADay() { + assertEventNear("midnight before 12:00", -70, -179.37, -12, LocalDate.of(2021, 1, 1), + AstronomicalCalendar::getSolarMidnight, 1609588897446L); // 2021-01-02T00:01:37-12:00 + } + + /** Solar noon before 0 hours local mean time moves forward a day. */ + @Test + public void solarNoonBeforeMidnightLocalMeanTimeMovesForwardADay() { + assertEventNear("noon before 00:00", -70, -179.37, -12, LocalDate.of(2021, 1, 1), + AstronomicalCalendar::getSunTransit, 1609545683526L); // 2021-01-01T12:01:23-12:00 + } + + /** Solar noon after 24 hours local mean time moves back a day. */ + @Test + public void solarNoonAfterTwentyFourHoursLocalMeanTimeMovesBackADay() { + assertEventNear("noon after 24:00", -70, 179, 12, LocalDate.of(2021, 9, 14), + AstronomicalCalendar::getSunTransit, 1631577581205L); // 2021-09-14T11:59:41+12:00 + } } From 6305205a74d4e7f6399bb975110f7e1fc4bf9f97 Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Thu, 22 Oct 2026 22:13:11 -0400 Subject: [PATCH 3/3] Explain each date adjustment in getInstantFromTime The comment now says why the UTC time of day alone doesn't give the date, what window of local mean time each event belongs to, and why each check moves the date, with an example of the wrap just west of the date line. --- .../zmanim/AstronomicalCalendar.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java b/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java index 9e1faad8..56f6b2bc 100644 --- a/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java +++ b/src/main/java/com/kosherjava/zmanim/AstronomicalCalendar.java @@ -553,9 +553,23 @@ protected Instant getInstantFromTime(double time, SolarEvent solarEvent) { LocalDate date = getAdjustedLocalDate(); double localTimeHours = (getGeoLocation().getLongitude() / 15) + time; - // A sunrise belongs to the date from 18:00 local mean time the evening before through 18:00, and a sunset from - // 06:00 through 06:00 the next morning. The time is in UTC hours folded into [0, 24), so near the date line an - // event can fold either way out of its window. + // The calculator gives only the UTC time of day, a number from 0 to 24. It doesn't say which UTC day the event + // is on, so start from the calculation date and correct it. + // + // localTimeHours is that time in local mean time, which is UTC plus the longitude's hours. It can come out below 0 or + // above 24, which means the event is really on the day before or the day after the date. Each event has a + // window of local mean time that belongs to the date, and these checks move the date by a day when the event + // falls outside it. + // + // - Sunrise belongs to the date from 18:00 the evening before through 18:00 of the date. After 18:00 it is + // really the evening before (an Arctic sunrise just after midnight, say), so go back a day. At -6:00 or + // earlier the UTC time wrapped around midnight, so go forward a day. For example, just west of the date line + // local mean time is about 12 hours behind UTC. A sunrise at noon local mean time is then 24:00 UTC, which + // arrives as 0:00 and reads as -12:00 local mean time. + // - Sunset belongs from 06:00 of the date through 06:00 the next morning. Before 06:00 go forward a day. At + // 30:00, which is 06:00 the next morning, or later the UTC time wrapped the other way, so go back a day. + // - Solar midnight is the one after the date's noon, so one before 12:00 moves forward a day. + // - Solar noon belongs between 0:00 and 24:00. Before 0:00 go forward a day, and after 24:00 go back a day. if (solarEvent == SolarEvent.SUNRISE && localTimeHours > 18) { date = date.minusDays(1); } else if (solarEvent == SolarEvent.SUNRISE && localTimeHours <= -6) {