From 033618f709d9e4232948590bea62923f746013b9 Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Wed, 21 Oct 2026 19:32:54 -0400 Subject: [PATCH] SPACalculator: return NaN when the rise/set iteration does not converge Near the poles the closed-form first guess can report an event the topocentric Sun never reaches. The secant iteration then diverges and solveRiseSet returned its last guess, an arbitrary time. Return NaN when the final zenith residual is above 1e-4 degrees. Co-Authored-By: Claude Opus 5.5 --- .../com/kosherjava/zmanim/util/SPACalculator.java | 14 ++++++++++++++ .../kosherjava/zmanim/util/SPACalculatorTest.java | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/main/java/com/kosherjava/zmanim/util/SPACalculator.java b/src/main/java/com/kosherjava/zmanim/util/SPACalculator.java index 93651f86..77964252 100644 --- a/src/main/java/com/kosherjava/zmanim/util/SPACalculator.java +++ b/src/main/java/com/kosherjava/zmanim/util/SPACalculator.java @@ -79,6 +79,15 @@ public class SPACalculator extends AstronomicalCalculator { /** Julian days per century. */ private static final double JULIAN_DAYS_PER_CENTURY = 36525.0; + /** + * The largest difference, in degrees, between the Sun's zenith at the solved time and the requested zenith that is still + * accepted as a sunrise or sunset. When the secant iteration in {@link #solveRiseSet} ends further off than this, it did + * not converge, and there is no event on that date. Converged solutions end below 1e-6° (floating-point noise in the + * Julian day), and failed ones almost always above 1e-3°. 1e-4° leaves a wide margin above the noise and is still + * below the SPA's stated accuracy of ±0.0003°. + */ + private static final double MAX_ZENITH_RESIDUAL = 1e-4; + /** Whether to apply the ΔT (TT−UT) correction. Defaults to {@code true}. */ private boolean applyDeltaT = true; @@ -331,6 +340,11 @@ private double solveRiseSet(LocalDate localDate, GeoLocation geoLocation, double t1 = t2; f1 = topocentricTrueZenith(jdDay + t1 / 1440.0, lat, geoLocation.getLongitude(), elevation) - adjustedZenith; } + // Near the poles the closed-form guess can report an event the topocentric Sun never reaches. The iteration then + // wanders off without converging, and the last guess is an arbitrary time, not a solution. + if (!(Math.abs(f1) <= MAX_ZENITH_RESIDUAL)) { + return Double.NaN; + } return t1; } diff --git a/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java b/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java index 18cc1357..b9f8c58b 100644 --- a/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java +++ b/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java @@ -116,6 +116,19 @@ public void getUTCSunset() { } } + /** + * Near the South Pole in March the closed-form first guess says the Sun reaches 10.2° below the horizon, but the + * topocentric Sun only gets to about 10.04°. The secant iteration never converges and used to return an arbitrary + * time. There is no event, so the result must be NaN. + */ + @Test + public void unconvergedRiseSetIsNoEvent() { + GeoLocation location = new GeoLocation("Antarctica", -77.26876171057897, -92.86928302120835, 3965.3333110036533, + java.time.ZoneId.of("Etc/GMT+6")); + double actual = calculator().getUTCSunrise(date("2127-03-14"), location, 100.2, false); + assertTrue("Sun never reaches 100.2° zenith; expected NaN but got " + actual, Double.isNaN(actual)); + } + @Test public void getUTCNoon() { Object[][] cases = {