diff --git a/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java b/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java
new file mode 100644
index 00000000..29bcafc1
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/AstronomicalCalendarTest.java
@@ -0,0 +1,121 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+import com.kosherjava.zmanim.util.SunTimesCalculator;
+
+/**
+ * Regression and behavioral coverage for {@link AstronomicalCalendar}. The time values are the library's own current
+ * output for the fixture (Lakewood, NJ on 2017-10-17); the behavioral tests exercise the polar no-event {@code null}
+ * contract, calculator swapping, and the twilight/offset relationships. (Complements the existing
+ * {@link AstronomicalCalendarRegressionTest}, which covers local-mean-time/DST edge cases.)
+ *
+ * @author Test coverage
+ */
+public class AstronomicalCalendarTest {
+
+ private static final LocalDate FIXTURE_DATE = LocalDate.of(2017, 10, 17);
+
+ private AstronomicalCalendar fixtureCalendar() {
+ AstronomicalCalendar calendar = new AstronomicalCalendar(TestLocations.lakewood());
+ calendar.setLocalDate(FIXTURE_DATE);
+ return calendar;
+ }
+
+ private void assertInstant(String label, String expectedIso, Instant actual) {
+ assertEquals(label, Instant.parse(expectedIso), actual);
+ }
+
+ @Test
+ public void sunriseAndSunset() {
+ AstronomicalCalendar calendar = fixtureCalendar();
+ assertInstant("sunrise", "2017-10-17T11:09:11.571783718Z", calendar.getSunrise());
+ assertInstant("sunset", "2017-10-17T22:14:38.994862349Z", calendar.getSunset());
+ assertInstant("seaLevelSunrise", "2017-10-17T11:09:51.403184642Z", calendar.getSeaLevelSunrise());
+ assertInstant("seaLevelSunset", "2017-10-17T22:13:59.201432122Z", calendar.getSeaLevelSunset());
+ }
+
+ @Test
+ public void transitAndSolarMidnight() {
+ AstronomicalCalendar calendar = fixtureCalendar();
+ assertInstant("sunTransit", "2017-10-17T16:42:12.781249470Z", calendar.getSunTransit());
+ assertInstant("solarMidnight", "2017-10-18T04:42:06.833038724Z", calendar.getSolarMidnight());
+ }
+
+ @Test
+ public void twilights() {
+ AstronomicalCalendar calendar = fixtureCalendar();
+ assertInstant("beginCivil", "2017-10-17T10:42:27.439221886Z", calendar.getBeginCivilTwilight());
+ assertInstant("beginNautical", "2017-10-17T10:10:57.242471901Z", calendar.getBeginNauticalTwilight());
+ assertInstant("beginAstronomical", "2017-10-17T09:39:33.523030241Z", calendar.getBeginAstronomicalTwilight());
+ assertInstant("endCivil", "2017-10-17T22:41:21.435143220Z", calendar.getEndCivilTwilight());
+ assertInstant("endNautical", "2017-10-17T23:12:49.151356447Z", calendar.getEndNauticalTwilight());
+ assertInstant("endAstronomical", "2017-10-17T23:44:09.707296295Z", calendar.getEndAstronomicalTwilight());
+ }
+
+ @Test
+ public void temporalHour() {
+ assertEquals(Duration.parse("PT55M20.649853956S"), fixtureCalendar().getTemporalHour());
+ }
+
+ /**
+ * The offset-by-degrees helpers underpin the named twilight methods, so passing the civil zenith (96°) must
+ * reproduce civil twilight exactly.
+ */
+ @Test
+ public void offsetByDegreesMatchesNamedTwilight() {
+ AstronomicalCalendar calendar = fixtureCalendar();
+ assertEquals(calendar.getBeginCivilTwilight(), calendar.getSunriseOffsetByDegrees(96.0));
+ assertEquals(calendar.getEndCivilTwilight(), calendar.getSunsetOffsetByDegrees(96.0));
+ }
+
+ /**
+ * Inside the Arctic Circle the sun neither rises nor sets on the summer solstice, so the rise/set based times must
+ * be {@code null} rather than throw.
+ */
+ @Test
+ public void polarNoEventReturnsNull() {
+ AstronomicalCalendar calendar = new AstronomicalCalendar(TestLocations.norway(TestLocations.UTC));
+ calendar.setLocalDate(LocalDate.of(2017, 6, 21));
+ assertNull("polar sunrise", calendar.getSunrise());
+ assertNull("polar sunset", calendar.getSunset());
+ }
+
+ /**
+ * The calculation engine is pluggable; the NOAA and USNO algorithms differ slightly, so swapping the calculator
+ * must change the result.
+ */
+ @Test
+ public void swappingCalculatorChangesResult() {
+ AstronomicalCalendar noaa = fixtureCalendar();
+ Instant noaaSunset = noaa.getSunset();
+
+ AstronomicalCalendar sunTimes = fixtureCalendar();
+ sunTimes.setAstronomicalCalculator(new SunTimesCalculator());
+ Instant sunTimesSunset = sunTimes.getSunset();
+
+ assertNotEquals(noaaSunset, sunTimesSunset);
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/ComprehensiveZmanimCalendarTest.java b/src/test/java/com/kosherjava/zmanim/ComprehensiveZmanimCalendarTest.java
new file mode 100644
index 00000000..1a970cab
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/ComprehensiveZmanimCalendarTest.java
@@ -0,0 +1,300 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim;
+
+import static org.junit.Assert.assertEquals;
+
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeSet;
+
+import org.junit.Test;
+
+/**
+ * A comprehensive regression net over every zero-argument zman method of {@link ComprehensiveZmanimCalendar}. Because
+ * the zmanim here encode particular halachic opinions (rather than a single verifiable astronomical value), these are
+ * pinned against the library's current output for a fixed fixture (Lakewood, NJ on 2017-10-17): any future change
+ * that shifts a result will be caught. Values that are {@code null} for this date (e.g. the chametz and Kidush Levana
+ * times, which only apply on specific days) are asserted to remain {@code null}.
+ *
+ * The test reflects over the calendar's public {@code get*}/{@code is*} methods returning {@link Instant} or
+ * {@link Duration} and compares each to the golden table below. It also asserts that the set of such methods
+ * matches the table, so a newly added or removed zman method is flagged (and its expected value must be recorded here).
+ * The golden values were generated from the library itself; regenerate with {@code RegressionValueDumper} if the
+ * fixture or a calculation intentionally changes.
+ *
+ * @author Test coverage
+ */
+public class ComprehensiveZmanimCalendarTest {
+
+ private static final LocalDate FIXTURE_DATE = LocalDate.of(2017, 10, 17);
+
+ /** Golden output: method name -> expected {@link Instant}/{@link Duration} string, or {@code null}. */
+ private static final Object[][] EXPECTED = {
+ { "getAlos120Minutes", "2017-10-17T09:09:51.403184642Z" },
+ { "getAlos120Zmanis", "2017-10-17T09:19:10.103476730Z" },
+ { "getAlos16Point1Degrees", "2017-10-17T09:49:30.219906135Z" },
+ { "getAlos18Degrees", "2017-10-17T09:39:33.523030241Z" },
+ { "getAlos19Degrees", "2017-10-17T09:34:19.165263704Z" },
+ { "getAlos19Point8Degrees", "2017-10-17T09:30:07.461349050Z" },
+ { "getAlos26Degrees", "2017-10-17T08:57:25.521762580Z" },
+ { "getAlos60Minutes", "2017-10-17T10:09:51.403184642Z" },
+ { "getAlos72Minutes", "2017-10-17T09:57:51.403184642Z" },
+ { "getAlos72Zmanis", "2017-10-17T10:03:26.623359895Z" },
+ { "getAlos90Minutes", "2017-10-17T09:39:51.403184642Z" },
+ { "getAlos90Zmanis", "2017-10-17T09:46:50.428403708Z" },
+ { "getAlos96Minutes", "2017-10-17T09:33:51.403184642Z" },
+ { "getAlos96Zmanis", "2017-10-17T09:41:18.363418313Z" },
+ { "getAlosBaalHatanya", "2017-10-17T09:45:19.050001055Z" },
+ { "getBainHashmashosRT13Point24Degrees", "2017-10-17T23:19:17.914866983Z" },
+ { "getBainHashmashosRT13Point5MinutesBefore7Point083Degrees", "2017-10-17T22:33:33.446858525Z" },
+ { "getBainHashmashosRT2Stars", "2017-10-17T22:41:41.407497564Z" },
+ { "getBainHashmashosRT58Point5Minutes", "2017-10-17T23:12:29.201432122Z" },
+ { "getBainHashmashosYereim13Point5Minutes", "2017-10-17T22:00:29.201432122Z" },
+ { "getBainHashmashosYereim16Point875Minutes", "2017-10-17T21:57:06.701432122Z" },
+ { "getBainHashmashosYereim18Minutes", "2017-10-17T21:55:59.201432122Z" },
+ { "getBainHashmashosYereim2Point1Degrees", "2017-10-17T21:58:15.259245832Z" },
+ { "getBainHashmashosYereim2Point8Degrees", "2017-10-17T21:54:28.539077180Z" },
+ { "getBainHashmashosYereim3Point05Degrees", "2017-10-17T21:53:07.410874002Z" },
+ { "getBeginAstronomicalTwilight", "2017-10-17T09:39:33.523030241Z" },
+ { "getBeginCivilTwilight", "2017-10-17T10:42:27.439221886Z" },
+ { "getBeginNauticalTwilight", "2017-10-17T10:10:57.242471901Z" },
+ { "getCandleLighting", "2017-10-17T21:55:59.201432122Z" },
+ { "getChatzosHalayla", "2017-10-18T04:42:06.833038724Z" },
+ { "getChatzosHayom", "2017-10-17T16:42:12.781249470Z" },
+ { "getChatzosHayomAsHalfDay", "2017-10-17T16:41:55.302308378Z" },
+ { "getEndAstronomicalTwilight", "2017-10-17T23:44:09.707296295Z" },
+ { "getEndCivilTwilight", "2017-10-17T22:41:21.435143220Z" },
+ { "getEndNauticalTwilight", "2017-10-17T23:12:49.151356447Z" },
+ { "getFixedLocalChatzosHayom", "2017-10-17T16:56:57.605832Z" },
+ { "getMinchaGedola16Point1Degrees", "2017-10-17T17:16:13.988705527Z" },
+ { "getMinchaGedola30Minutes", "2017-10-17T17:12:12.781249470Z" },
+ { "getMinchaGedola72Minutes", "2017-10-17T17:15:35.627235356Z" },
+ { "getMinchaGedolaAhavatShalom", "2017-10-17T17:13:52.057872856Z" },
+ { "getMinchaGedolaAteretTorah", "2017-10-17T17:00:49.269815683Z" },
+ { "getMinchaGedolaBaalHatanya", "2017-10-17T17:09:55.476611672Z" },
+ { "getMinchaGedolaGRA", "2017-10-17T17:09:35.627235356Z" },
+ { "getMinchaGedolaGRAFixedLocalChatzos30Minutes", "2017-10-17T17:26:57.605832Z" },
+ { "getMinchaGedolaGRAGreaterThan30", "2017-10-17T17:12:12.781249470Z" },
+ { "getMinchaKetana16Point1Degrees", "2017-10-17T20:42:24.958920631Z" },
+ { "getMinchaKetana72Minutes", "2017-10-17T20:37:37.576797224Z" },
+ { "getMinchaKetanaAhavatShalom", "2017-10-17T19:51:21.615491489Z" },
+ { "getMinchaKetanaAteretTorah", "2017-10-17T20:13:27.414333739Z" },
+ { "getMinchaKetanaBaalHatanya", "2017-10-17T19:57:57.216239627Z" },
+ { "getMinchaKetanaGRA", "2017-10-17T19:55:37.576797224Z" },
+ { "getMinchaKetanaGRAFixedLocalChatzosToSunset", "2017-10-17T20:01:53.536598735Z" },
+ { "getMisheyakir10Point2Degrees", "2017-10-17T10:20:22.960720797Z" },
+ { "getMisheyakir11Degrees", "2017-10-17T10:16:11.433376090Z" },
+ { "getMisheyakir11Point5Degrees", "2017-10-17T10:13:34.311221155Z" },
+ { "getMisheyakir12Point85Degrees", "2017-10-17T10:06:30.324568897Z" },
+ { "getMisheyakir7Point65Degrees", "2017-10-17T10:33:46.155182911Z" },
+ { "getMisheyakir9Point5Degrees", "2017-10-17T10:24:03.203710034Z" },
+ { "getPlagAhavatShalom", "2017-10-17T21:10:33.114910614Z" },
+ { "getPlagAlos16Point1DegreesToTzaisGeonim7Point083Degrees", "2017-10-17T21:26:03.735717649Z" },
+ { "getPlagAlosToSunset", "2017-10-17T20:56:26.182523158Z" },
+ { "getPlagHamincha120Minutes", "2017-10-17T22:39:48.389114669Z" },
+ { "getPlagHamincha120MinutesZmanis", "2017-10-17T22:32:26.084716766Z" },
+ { "getPlagHamincha16Point1Degrees", "2017-10-17T22:08:19.529843591Z" },
+ { "getPlagHamincha18Degrees", "2017-10-17T22:16:10.938101909Z" },
+ { "getPlagHamincha19Point8Degrees", "2017-10-17T22:23:38.065797743Z" },
+ { "getPlagHamincha26Degrees", "2017-10-17T22:49:27.074658511Z" },
+ { "getPlagHamincha60Minutes", "2017-10-17T21:52:18.389114669Z" },
+ { "getPlagHamincha72Minutes", "2017-10-17T22:01:48.389114669Z" },
+ { "getPlagHamincha72MinutesZmanis", "2017-10-17T21:57:23.006475925Z" },
+ { "getPlagHamincha90Minutes", "2017-10-17T22:16:03.389114669Z" },
+ { "getPlagHamincha90MinutesZmanis", "2017-10-17T22:10:31.660816241Z" },
+ { "getPlagHamincha96Minutes", "2017-10-17T22:20:48.389114669Z" },
+ { "getPlagHamincha96MinutesZmanis", "2017-10-17T22:14:54.545596351Z" },
+ { "getPlagHaminchaAteretTorah", "2017-10-17T21:33:43.307882929Z" },
+ { "getPlagHaminchaBaalHatanya", "2017-10-17T21:07:57.941084608Z" },
+ { "getPlagHaminchaGRA", "2017-10-17T21:04:48.389114669Z" },
+ { "getPlagHaminchaGRAFixedLocalChatzosToSunset", "2017-10-17T21:07:56.369015426Z" },
+ { "getPolarPlagHaminchaBenIshChai", null },
+ { "getPolarPlagHaminchaTeshuvosVehanhagos", null },
+ { "getPolarStartOfDayTeshuvosVehanhagos", null },
+ { "getPolarSunriseBenIshChai", null },
+ { "getPolarSunsetBenIshChai", null },
+ { "getSamuchLeMinchaKetana16Point1Degrees", "2017-10-17T20:08:03.130551447Z" },
+ { "getSamuchLeMinchaKetana72Minutes", "2017-10-17T20:03:57.251870246Z" },
+ { "getSamuchLeMinchaKetanaGRA", "2017-10-17T19:27:57.251870246Z" },
+ { "getSeaLevelSunrise", "2017-10-17T11:09:51.403184642Z" },
+ { "getSeaLevelSunset", "2017-10-17T22:13:59.201432122Z" },
+ { "getShaahZmanis120Minutes", "PT1H15M20.649853956S" },
+ { "getShaahZmanis120MinutesZmanis", "PT1H13M47.533138608S" },
+ { "getShaahZmanis16Point1Degrees", "PT1H8M43.656738368S" },
+ { "getShaahZmanis18Degrees", "PT1H10M23.015355504S" },
+ { "getShaahZmanis19Point8Degrees", "PT1H11M57.265530111S" },
+ { "getShaahZmanis26Degrees", "PT1H17M23.865385668S" },
+ { "getShaahZmanis60Minutes", "PT1H5M20.649853956S" },
+ { "getShaahZmanis72Minutes", "PT1H7M20.649853956S" },
+ { "getShaahZmanis72MinutesZmanis", "PT1H6M24.779824747S" },
+ { "getShaahZmanis90Minutes", "PT1H10M20.649853956S" },
+ { "getShaahZmanis90MinutesZmanis", "PT1H9M10.812317445S" },
+ { "getShaahZmanis96Minutes", "PT1H11M20.649853956S" },
+ { "getShaahZmanis96MinutesZmanis", "PT1H10M6.156481678S" },
+ { "getShaahZmanisAlos16Point1DegreesToTzaisGeonim3Point7Degrees", "PT1H3M18.553246773S" },
+ { "getShaahZmanisAlos16Point1DegreesToTzaisGeonim3Point8Degrees", "PT1H3M21.1995353S" },
+ { "getShaahZmanisAlos16Point1DegreesToTzaisGeonim7Point083Degrees", "PT1H4M47.768912699S" },
+ { "getShaahZmanisAteretTorah", "PT1H4M12.714839352S" },
+ { "getShaahZmanisBaalHatanya", "PT56M0.579875985S" },
+ { "getShaahZmanisGRA", "PT55M20.649853956S" },
+ { "getSofZmanAchilasChametzBaalHatanya", null },
+ { "getSofZmanAchilasChametzGRA", null },
+ { "getSofZmanAchilasChametzMGA16Point1Degrees", null },
+ { "getSofZmanAchilasChametzMGA72Minutes", null },
+ { "getSofZmanAchilasChametzMGA72MinutesZmanis", null },
+ { "getSofZmanBiurChametzBaalHatanya", null },
+ { "getSofZmanBiurChametzGRA", null },
+ { "getSofZmanBiurChametzMGA16Point1Degrees", null },
+ { "getSofZmanBiurChametzMGA72Minutes", null },
+ { "getSofZmanBiurChametzMGA72MinutesZmanis", null },
+ { "getSofZmanKidushLevana15Days", null },
+ { "getSofZmanKidushLevanaBetweenMoldos", null },
+ { "getSofZmanShma3HoursBeforeChatzos", "2017-10-17T13:42:12.781249470Z" },
+ { "getSofZmanShmaAlos16Point1DegreesToTzaisGeonim7Point083Degrees", "2017-10-17T13:03:53.526644232Z" },
+ { "getSofZmanShmaAlos16Point1ToSunset", "2017-10-17T12:55:37.465287630Z" },
+ { "getSofZmanShmaAteretTorah", "2017-10-17T13:16:04.767877951Z" },
+ { "getSofZmanShmaBaalHatanya", "2017-10-17T13:53:53.447045725Z" },
+ { "getSofZmanShmaGRA", "2017-10-17T13:55:53.352746510Z" },
+ { "getSofZmanShmaGRASunriseToFixedLocalChatzos", "2017-10-17T14:03:24.504508319Z" },
+ { "getSofZmanShmaMGA120Minutes", "2017-10-17T12:55:53.352746510Z" },
+ { "getSofZmanShmaMGA16Point1Degrees", "2017-10-17T13:15:41.190121239Z" },
+ { "getSofZmanShmaMGA16Point1DegreesToFixedLocalChatzos", "2017-10-17T13:23:13.912869066Z" },
+ { "getSofZmanShmaMGA18Degrees", "2017-10-17T13:10:42.569096753Z" },
+ { "getSofZmanShmaMGA18DegreesToFixedLocalChatzos", "2017-10-17T13:18:15.564431120Z" },
+ { "getSofZmanShmaMGA19Point8Degrees", "2017-10-17T13:05:59.257939383Z" },
+ { "getSofZmanShmaMGA72Minutes", "2017-10-17T13:19:53.352746510Z" },
+ { "getSofZmanShmaMGA72MinutesToFixedLocalChatzos", "2017-10-17T13:27:24.504508319Z" },
+ { "getSofZmanShmaMGA72MinutesZmanis", "2017-10-17T13:22:40.962834136Z" },
+ { "getSofZmanShmaMGA90Minutes", "2017-10-17T13:10:53.352746510Z" },
+ { "getSofZmanShmaMGA90MinutesToFixedLocalChatzos", "2017-10-17T13:18:24.504508319Z" },
+ { "getSofZmanShmaMGA90MinutesZmanis", "2017-10-17T13:14:22.865356043Z" },
+ { "getSofZmanShmaMGA96Minutes", "2017-10-17T13:07:53.352746510Z" },
+ { "getSofZmanShmaMGA96MinutesZmanis", "2017-10-17T13:11:36.832863347Z" },
+ { "getSofZmanTfila2HoursBeforeChatzos", "2017-10-17T14:42:12.781249470Z" },
+ { "getSofZmanTfilaAteretTorah", "2017-10-17T14:20:17.482717303Z" },
+ { "getSofZmanTfilaBaalHatanya", "2017-10-17T14:49:54.026921710Z" },
+ { "getSofZmanTfilaGRA", "2017-10-17T14:51:14.002600466Z" },
+ { "getSofZmanTfilaGRASunriseToFixedLocalChatzos", "2017-10-17T15:01:15.538282878Z" },
+ { "getSofZmanTfilaMGA120Minutes", "2017-10-17T14:11:14.002600466Z" },
+ { "getSofZmanTfilaMGA16Point1Degrees", "2017-10-17T14:24:24.846859607Z" },
+ { "getSofZmanTfilaMGA18Degrees", "2017-10-17T14:21:05.584452257Z" },
+ { "getSofZmanTfilaMGA19Point8Degrees", "2017-10-17T14:17:56.523469494Z" },
+ { "getSofZmanTfilaMGA72Minutes", "2017-10-17T14:27:14.002600466Z" },
+ { "getSofZmanTfilaMGA72MinutesZmanis", "2017-10-17T14:29:05.742658883Z" },
+ { "getSofZmanTfilaMGA90Minutes", "2017-10-17T14:21:14.002600466Z" },
+ { "getSofZmanTfilaMGA90MinutesZmanis", "2017-10-17T14:23:33.677673488Z" },
+ { "getSofZmanTfilaMGA96Minutes", "2017-10-17T14:19:14.002600466Z" },
+ { "getSofZmanTfilaMGA96MinutesZmanis", "2017-10-17T14:21:42.989345025Z" },
+ { "getSolarMidnight", "2017-10-18T04:42:06.833038724Z" },
+ { "getSunTransit", "2017-10-17T16:42:12.781249470Z" },
+ { "getSunrise", "2017-10-17T11:09:11.571783718Z" },
+ { "getSunset", "2017-10-17T22:14:38.994862349Z" },
+ { "getTchilasZmanKidushLevana3Days", null },
+ { "getTchilasZmanKidushLevana7Days", null },
+ { "getTemporalHour", "PT55M20.649853956S" },
+ { "getTzais120Minutes", "2017-10-18T00:13:59.201432122Z" },
+ { "getTzais120Zmanis", "2017-10-18T00:04:40.501140034Z" },
+ { "getTzais16Point1Degrees", "2017-10-17T23:34:14.100766559Z" },
+ { "getTzais18Degrees", "2017-10-17T23:44:09.707296295Z" },
+ { "getTzais19Point8Degrees", "2017-10-17T23:53:34.647710389Z" },
+ { "getTzais26Degrees", "2017-10-18T00:26:11.906390602Z" },
+ { "getTzais50Minutes", "2017-10-17T23:03:59.201432122Z" },
+ { "getTzais60Minutes", "2017-10-17T23:13:59.201432122Z" },
+ { "getTzais72Minutes", "2017-10-17T23:25:59.201432122Z" },
+ { "getTzais72Zmanis", "2017-10-17T23:20:23.981256869Z" },
+ { "getTzais90Minutes", "2017-10-17T23:43:59.201432122Z" },
+ { "getTzais90Zmanis", "2017-10-17T23:37:00.176213056Z" },
+ { "getTzais96Minutes", "2017-10-17T23:49:59.201432122Z" },
+ { "getTzais96Zmanis", "2017-10-17T23:42:32.241198451Z" },
+ { "getTzaisAteretTorah", "2017-10-17T22:53:59.201432122Z" },
+ { "getTzaisBaalHatanya", "2017-10-17T22:41:21.435143220Z" },
+ { "getTzaisGeonim3Point7Degrees", "2017-10-17T22:29:12.858867416Z" },
+ { "getTzaisGeonim3Point8Degrees", "2017-10-17T22:29:44.614329739Z" },
+ { "getTzaisGeonim4Point42Degrees", "2017-10-17T22:33:01.330786672Z" },
+ { "getTzaisGeonim4Point66Degrees", "2017-10-17T22:34:17.404148028Z" },
+ { "getTzaisGeonim4Point8Degrees", "2017-10-17T22:35:01.761613150Z" },
+ { "getTzaisGeonim5Point95Degrees", "2017-10-17T22:41:05.633594744Z" },
+ { "getTzaisGeonim6Point45Degrees", "2017-10-17T22:43:43.582328133Z" },
+ { "getTzaisGeonim7Point083Degrees", "2017-10-17T22:47:03.446858525Z" },
+ { "getTzaisGeonim7Point67Degrees", "2017-10-17T22:50:08.395485971Z" },
+ { "getTzaisGeonim8Point5Degrees", "2017-10-17T22:54:29.772724455Z" },
+ { "getTzaisGeonim9Point3Degrees", "2017-10-17T22:58:41.421314719Z" },
+ { "getTzaisGeonim9Point75Degrees", "2017-10-17T23:01:02.865384009Z" },
+ { "getZmanMolad", null },
+ };
+
+ private ComprehensiveZmanimCalendar fixtureCalendar() {
+ ComprehensiveZmanimCalendar calendar = new ComprehensiveZmanimCalendar(TestLocations.lakewood());
+ calendar.setLocalDate(FIXTURE_DATE);
+ return calendar;
+ }
+
+ /** Invokes {@code methodName} on the calendar and returns its value as a comparable string, or {@code null}. */
+ private String actual(ComprehensiveZmanimCalendar calendar, String methodName) throws Exception {
+ Object value = calendar.getClass().getMethod(methodName).invoke(calendar);
+ if (value == null) {
+ return null;
+ }
+ return value instanceof Instant ? ((Instant) value).toString() : ((Duration) value).toString();
+ }
+
+ @Test
+ public void allZmanimMatchGoldenOutput() throws Exception {
+ ComprehensiveZmanimCalendar calendar = fixtureCalendar();
+
+ Map expected = new LinkedHashMap<>();
+ for (Object[] row : EXPECTED) {
+ expected.put((String) row[0], (String) row[1]);
+ }
+
+ for (Map.Entry entry : expected.entrySet()) {
+ assertEquals(entry.getKey(), entry.getValue(), actual(calendar, entry.getKey()));
+ }
+ }
+
+ /**
+ * Guards the golden table against drift: the set of zero-argument {@code get*}/{@code is*} methods returning an
+ * {@link Instant} or {@link Duration} must exactly match the recorded table. If a zman method is added or removed,
+ * this fails and the table above must be updated (regenerate with {@code RegressionValueDumper}).
+ */
+ @Test
+ public void goldenTableCoversEveryZmanMethod() {
+ TreeSet reflected = new TreeSet<>();
+ for (Method method : ComprehensiveZmanimCalendar.class.getMethods()) {
+ if (method.getParameterCount() != 0) {
+ continue;
+ }
+ if (!(method.getName().startsWith("get") || method.getName().startsWith("is"))) {
+ continue;
+ }
+ Class> returnType = method.getReturnType();
+ if (returnType == Instant.class || returnType == Duration.class) {
+ reflected.add(method.getName());
+ }
+ }
+
+ TreeSet recorded = new TreeSet<>();
+ for (Object[] row : EXPECTED) {
+ recorded.add((String) row[0]);
+ }
+
+ assertEquals(recorded, reflected);
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/RegressionValueDumper.java b/src/test/java/com/kosherjava/zmanim/RegressionValueDumper.java
new file mode 100644
index 00000000..92888c41
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/RegressionValueDumper.java
@@ -0,0 +1,96 @@
+package com.kosherjava.zmanim;
+
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.kosherjava.zmanim.hebrewcalendar.JewishDate;
+import com.kosherjava.zmanim.util.GeoLocation;
+
+/**
+ * Not a test (no "Test" suffix, so Surefire ignores it). A throwaway generator that dumps the library's current output
+ * for every zero-arg zman method so the values can be baked into the regression tests. Run via:
+ * {@code java -cp target/test-classes:target/classes com.kosherjava.zmanim.RegressionValueDumper <class>}, or with a
+ * Jewish date to exercise the date-gated zmanim:
+ * {@code ... RegressionValueDumper comprehensive jewish <year> <month> <day>}.
+ */
+public final class RegressionValueDumper {
+
+ public static void main(String[] args) throws Exception {
+ String which = args.length > 0 ? args[0] : "comprehensive";
+ GeoLocation lakewood = TestLocations.lakewood();
+ LocalDate date = LocalDate.of(2017, 10, 17);
+ if (args.length >= 5 && "jewish".equals(args[1])) {
+ date = new JewishDate(Integer.parseInt(args[2]), Integer.parseInt(args[3]), Integer.parseInt(args[4]))
+ .getLocalDate();
+ }
+ if (args.length >= 3 && "scan".equals(args[1])) {
+ // scan [startIsoDate] [days] [location]: print each date where the method is non-null
+ String methodName = args[2];
+ LocalDate start = args.length >= 4 ? LocalDate.parse(args[3]) : LocalDate.of(2017, 10, 1);
+ int days = args.length >= 5 ? Integer.parseInt(args[4]) : 40;
+ GeoLocation scanLocation = args.length >= 6 && "norway".equals(args[5])
+ ? TestLocations.norway(java.time.ZoneId.of("Europe/Oslo")) : lakewood;
+ ComprehensiveZmanimCalendar scanCal = new ComprehensiveZmanimCalendar(scanLocation);
+ for (int i = 0; i < days; i++) {
+ LocalDate d = start.plusDays(i);
+ scanCal.setLocalDate(d);
+ Object value = scanCal.getClass().getMethod(methodName).invoke(scanCal);
+ if (value != null) {
+ System.out.println(d + "\t" + methodName + "\t" + value);
+ }
+ }
+ return;
+ }
+
+ AstronomicalCalendar calendar;
+ switch (which) {
+ case "astronomical":
+ calendar = new AstronomicalCalendar(lakewood);
+ break;
+ case "zmanim":
+ calendar = new ZmanimCalendar(lakewood);
+ break;
+ default:
+ calendar = new ComprehensiveZmanimCalendar(lakewood);
+ break;
+ }
+ calendar.setLocalDate(date);
+
+ List names = new ArrayList<>();
+ for (Method method : calendar.getClass().getMethods()) {
+ if (method.getParameterCount() != 0) {
+ continue;
+ }
+ if (!(method.getName().startsWith("get") || method.getName().startsWith("is"))) {
+ continue;
+ }
+ Class> returnType = method.getReturnType();
+ if (returnType != Instant.class && returnType != Duration.class) {
+ continue;
+ }
+ names.add(method.getName());
+ }
+ names.sort(String::compareTo);
+
+ for (String name : names) {
+ Object value = calendar.getClass().getMethod(name).invoke(calendar);
+ String formatted;
+ if (value == null) {
+ formatted = "null";
+ } else if (value instanceof Instant) {
+ formatted = ((Instant) value).toString();
+ } else {
+ formatted = ((Duration) value).toString();
+ }
+ System.out.println(name + "\t" + formatted);
+ }
+ System.out.println("# total: " + names.size());
+ }
+
+ private RegressionValueDumper() {
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/SpecialDayZmanimTest.java b/src/test/java/com/kosherjava/zmanim/SpecialDayZmanimTest.java
new file mode 100644
index 00000000..b8410d43
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/SpecialDayZmanimTest.java
@@ -0,0 +1,150 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+import org.junit.Test;
+
+/**
+ * Coverage for the location- and date-gated zmanim of {@link ComprehensiveZmanimCalendar} that only produce a value
+ * under specific conditions: the chametz times (only on Erev Pesach, 14 Nissan), the Kidush Levana /
+ * molad times (only within their lunar-month windows), and the polar zmanim (only inside the Arctic/Antarctic
+ * circle during polar day/night, when there is no ordinary sunrise or sunset). The broader
+ * {@link ComprehensiveZmanimCalendarTest} net pins these as {@code null} for its temperate ordinary-day fixture; this
+ * test verifies they return the correct times under the conditions they actually apply.
+ *
+ * @author Test coverage
+ */
+public class SpecialDayZmanimTest {
+
+ private ComprehensiveZmanimCalendar calendarFor(LocalDate date) {
+ ComprehensiveZmanimCalendar calendar = new ComprehensiveZmanimCalendar(TestLocations.lakewood());
+ calendar.setLocalDate(date);
+ return calendar;
+ }
+
+ private void assertInstant(String label, String expectedIso, Instant actual) {
+ assertEquals(label, Instant.parse(expectedIso), actual);
+ }
+
+ /** 14 Nissan 5777 (Erev Pesach) falls on 2017-04-10. */
+ private static final LocalDate EREV_PESACH = LocalDate.of(2017, 4, 10);
+
+ @Test
+ public void chametzTimesOnErevPesach() {
+ ComprehensiveZmanimCalendar calendar = calendarFor(EREV_PESACH);
+
+ // All ten sof zman achilas/biur chametz opinions must produce a time on Erev Pesach.
+ assertNotNull(calendar.getSofZmanAchilasChametzGRA());
+ assertNotNull(calendar.getSofZmanAchilasChametzMGA72Minutes());
+ assertNotNull(calendar.getSofZmanAchilasChametzMGA72MinutesZmanis());
+ assertNotNull(calendar.getSofZmanAchilasChametzMGA16Point1Degrees());
+ assertNotNull(calendar.getSofZmanAchilasChametzBaalHatanya());
+ assertNotNull(calendar.getSofZmanBiurChametzGRA());
+ assertNotNull(calendar.getSofZmanBiurChametzMGA72Minutes());
+ assertNotNull(calendar.getSofZmanBiurChametzMGA72MinutesZmanis());
+ assertNotNull(calendar.getSofZmanBiurChametzMGA16Point1Degrees());
+ assertNotNull(calendar.getSofZmanBiurChametzBaalHatanya());
+
+ // Regression on the GRA opinions; biur (end of the 5th hour) is one shaah zmanis after achilas (end of the 4th).
+ assertInstant("achilasGRA", "2017-04-10T14:47:45.675920561Z", calendar.getSofZmanAchilasChametzGRA());
+ assertInstant("biurGRA", "2017-04-10T15:53:07.762314340Z", calendar.getSofZmanBiurChametzGRA());
+ }
+
+ @Test
+ public void chametzTimesAreNullOnAnOrdinaryDay() {
+ ComprehensiveZmanimCalendar calendar = calendarFor(LocalDate.of(2017, 10, 17));
+ assertNull(calendar.getSofZmanAchilasChametzGRA());
+ assertNull(calendar.getSofZmanBiurChametzGRA());
+ }
+
+ @Test
+ public void zmanMoladAndTchilasKidushLevana() {
+ // The molad of Cheshvan 5778 (the moment itself falls on 2017-10-20).
+ assertInstant("zmanMolad", "2017-10-20T09:52:00.170666666Z", calendarFor(LocalDate.of(2017, 10, 20)).getZmanMolad());
+
+ // Earliest Kidush Levana: 3 days and 7 days after the molad.
+ assertInstant("tchilas3Days", "2017-10-23T09:52:00.170666666Z",
+ calendarFor(LocalDate.of(2017, 10, 23)).getTchilasZmanKidushLevana3Days());
+ assertInstant("tchilas7Days", "2017-10-27T09:52:00.170666666Z",
+ calendarFor(LocalDate.of(2017, 10, 27)).getTchilasZmanKidushLevana7Days());
+ }
+
+ @Test
+ public void sofZmanKidushLevana() {
+ // Latest Kidush Levana for the month whose molad was in late September 2017 (sayable through 2017-10-05).
+ assertInstant("sof15Days", "2017-10-05T21:07:56.837333333Z",
+ calendarFor(LocalDate.of(2017, 10, 5)).getSofZmanKidushLevana15Days());
+ assertInstant("sofBetweenMoldos", "2017-10-05T15:29:58.503333333Z",
+ calendarFor(LocalDate.of(2017, 10, 5)).getSofZmanKidushLevanaBetweenMoldos());
+ }
+
+ @Test
+ public void kidushLevanaIsNullOutsideItsWindow() {
+ // 2017-10-17 is past the previous month's sof zman and before the next month's tchilas zman.
+ ComprehensiveZmanimCalendar calendar = calendarFor(LocalDate.of(2017, 10, 17));
+ assertNull(calendar.getZmanMolad());
+ assertNull(calendar.getTchilasZmanKidushLevana3Days());
+ assertNull(calendar.getSofZmanKidushLevana15Days());
+ }
+
+ private ComprehensiveZmanimCalendar norwayCalendarFor(LocalDate date) {
+ ComprehensiveZmanimCalendar calendar =
+ new ComprehensiveZmanimCalendar(TestLocations.norway(ZoneId.of("Europe/Oslo")));
+ calendar.setLocalDate(date);
+ return calendar;
+ }
+
+ /** 2017-06-21 (summer solstice) is inside the polar day at the Norway fixture: no ordinary sunrise or sunset. */
+ private static final LocalDate NORWAY_POLAR_DAY = LocalDate.of(2017, 6, 21);
+
+ @Test
+ public void polarZmanimDuringPolarDay() {
+ ComprehensiveZmanimCalendar calendar = norwayCalendarFor(NORWAY_POLAR_DAY);
+
+ // Precondition: there is genuinely no sunrise/sunset here, which is what activates the polar alternatives.
+ assertNull(calendar.getSunrise());
+ assertNull(calendar.getSunset());
+
+ // Ben Ish Chai: use the sun's crossing of due east (90) / due west (270) as the "sunrise"/"sunset".
+ assertInstant("polarSunriseBenIshChai", "2017-06-21T06:01:13.021342105Z", calendar.getPolarSunriseBenIshChai());
+ assertInstant("polarSunsetBenIshChai", "2017-06-21T16:49:17.587975180Z", calendar.getPolarSunsetBenIshChai());
+ assertInstant("polarPlagBenIshChai", "2017-06-21T15:41:47.112284232Z", calendar.getPolarPlagHaminchaBenIshChai());
+
+ // Teshuvos Vehanhagos: during polar summer the start of day is chatzos halayla.
+ assertInstant("polarStartOfDayTeshuvos", "2017-06-21T23:25:21.765329979Z",
+ calendar.getPolarStartOfDayTeshuvosVehanhagos());
+ assertInstant("polarPlagTeshuvos", "2017-06-21T20:55:21.765329979Z",
+ calendar.getPolarPlagHaminchaTeshuvosVehanhagos());
+ }
+
+ @Test
+ public void polarZmanimAreNullWhereSunRisesAndSets() {
+ // At the temperate Lakewood fixture the sun rises and sets normally, so the polar alternatives do not apply.
+ ComprehensiveZmanimCalendar calendar = calendarFor(LocalDate.of(2017, 10, 17));
+ assertNull(calendar.getPolarSunriseBenIshChai());
+ assertNull(calendar.getPolarSunsetBenIshChai());
+ assertNull(calendar.getPolarPlagHaminchaBenIshChai());
+ assertNull(calendar.getPolarStartOfDayTeshuvosVehanhagos());
+ assertNull(calendar.getPolarPlagHaminchaTeshuvosVehanhagos());
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/TestLocations.java b/src/test/java/com/kosherjava/zmanim/TestLocations.java
new file mode 100644
index 00000000..a1e00f1c
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/TestLocations.java
@@ -0,0 +1,121 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim;
+
+import java.time.ZoneId;
+
+import com.kosherjava.zmanim.util.GeoLocation;
+
+/**
+ * Shared test fixture holding the reference locations used across the calculator and calendar test suites. The
+ * locations span both hemispheres, the equator, the dateline and the Arctic Circle to exercise a range of edge cases.
+ * Each location carries its real {@link ZoneId} for the calendar-level tests (the low-level calculator tests operate
+ * purely in UTC hours and are unaffected by the zone).
+ *
+ * @author Test coverage
+ */
+public final class TestLocations {
+
+ private TestLocations() {
+ // utility holder; not instantiable
+ }
+
+ /** Woodcliff Lake area, New Jersey. */
+ public static final double NJ_LAT = 41.1181036;
+ public static final double NJ_LON = -74.0840691;
+ public static final double NJ_ELEV = 167;
+
+ /** Los Angeles area, California. */
+ public static final double LA_LAT = 34.0201613;
+ public static final double LA_LON = -118.6919095;
+ public static final double LA_ELEV = 71;
+
+ /** Jerusalem, Israel. */
+ public static final double JERUSALEM_LAT = 31.7962994;
+ public static final double JERUSALEM_LON = 35.1053185;
+ public static final double JERUSALEM_ELEV = 754;
+
+ /** Northern Norway (inside the Arctic Circle - polar day/night edge cases). */
+ public static final double NORWAY_LAT = 70.1498248;
+ public static final double NORWAY_LON = 9.1456867;
+ public static final double NORWAY_ELEV = 0;
+
+ /** Sydney, Australia (southern hemisphere). */
+ public static final double SYDNEY_LAT = -33.8688;
+ public static final double SYDNEY_LON = 151.2093;
+ public static final double SYDNEY_ELEV = 58;
+
+ /** Macapá, Brazil (on the equator). */
+ public static final double MACAPA_LAT = 0.0349;
+ public static final double MACAPA_LON = -51.0694;
+ public static final double MACAPA_ELEV = 15;
+
+ /** Suva, Fiji (near the dateline). */
+ public static final double SUVA_LAT = -18.1416;
+ public static final double SUVA_LON = 178.4419;
+ public static final double SUVA_ELEV = 6;
+
+ /** Ushuaia, Argentina (far southern latitude). */
+ public static final double USHUAIA_LAT = -54.8019;
+ public static final double USHUAIA_LON = -68.3030;
+ public static final double USHUAIA_ELEV = 23;
+
+ /** Lakewood, New Jersey - the library's canonical reference location. */
+ public static final double LAKEWOOD_LAT = 40.0721087;
+ public static final double LAKEWOOD_LON = -74.2400243;
+ public static final double LAKEWOOD_ELEV = 15;
+
+ /**
+ * A {@link ZoneId} of {@code UTC}, used for the low-level calculator tests where the calculation is done entirely in
+ * UTC hours and the zone is irrelevant.
+ */
+ public static final ZoneId UTC = ZoneId.of("UTC");
+
+ public static GeoLocation nj(ZoneId zoneId) {
+ return new GeoLocation("Woodcliff Lake, NJ", NJ_LAT, NJ_LON, NJ_ELEV, zoneId);
+ }
+
+ public static GeoLocation la(ZoneId zoneId) {
+ return new GeoLocation("Los Angeles, CA", LA_LAT, LA_LON, LA_ELEV, zoneId);
+ }
+
+ public static GeoLocation jerusalem(ZoneId zoneId) {
+ return new GeoLocation("Jerusalem, Israel", JERUSALEM_LAT, JERUSALEM_LON, JERUSALEM_ELEV, zoneId);
+ }
+
+ public static GeoLocation norway(ZoneId zoneId) {
+ return new GeoLocation("Northern Norway", NORWAY_LAT, NORWAY_LON, NORWAY_ELEV, zoneId);
+ }
+
+ public static GeoLocation sydney(ZoneId zoneId) {
+ return new GeoLocation("Sydney, Australia", SYDNEY_LAT, SYDNEY_LON, SYDNEY_ELEV, zoneId);
+ }
+
+ public static GeoLocation macapa(ZoneId zoneId) {
+ return new GeoLocation("Macapá, Brazil", MACAPA_LAT, MACAPA_LON, MACAPA_ELEV, zoneId);
+ }
+
+ public static GeoLocation suva(ZoneId zoneId) {
+ return new GeoLocation("Suva, Fiji", SUVA_LAT, SUVA_LON, SUVA_ELEV, zoneId);
+ }
+
+ public static GeoLocation ushuaia(ZoneId zoneId) {
+ return new GeoLocation("Ushuaia, Argentina", USHUAIA_LAT, USHUAIA_LON, USHUAIA_ELEV, zoneId);
+ }
+
+ public static GeoLocation lakewood() {
+ return new GeoLocation("Lakewood, NJ", LAKEWOOD_LAT, LAKEWOOD_LON, LAKEWOOD_ELEV, ZoneId.of("America/New_York"));
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/ZmanimCalendarTest.java b/src/test/java/com/kosherjava/zmanim/ZmanimCalendarTest.java
new file mode 100644
index 00000000..be041f45
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/ZmanimCalendarTest.java
@@ -0,0 +1,111 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+/**
+ * Regression and behavioral coverage for {@link ZmanimCalendar}, the standard-zmanim entry point. Every zero-argument
+ * zman getter is pinned against the library's own current output for the fixture (Lakewood, NJ on 2017-10-17), and the
+ * elevation toggle is verified to actually move the sunrise-based zmanim. (The full opinion-heavy surface is covered by
+ * {@link ComprehensiveZmanimCalendarTest}.)
+ *
+ * @author Test coverage
+ */
+public class ZmanimCalendarTest {
+
+ private static final LocalDate FIXTURE_DATE = LocalDate.of(2017, 10, 17);
+
+ private ZmanimCalendar fixtureCalendar() {
+ ZmanimCalendar calendar = new ZmanimCalendar(TestLocations.lakewood());
+ calendar.setLocalDate(FIXTURE_DATE);
+ return calendar;
+ }
+
+ private void assertInstant(String label, String expectedIso, Instant actual) {
+ assertEquals(label, Instant.parse(expectedIso), actual);
+ }
+
+ @Test
+ public void alosAndMisheyakir() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ assertInstant("alos16.1", "2017-10-17T09:49:30.219906135Z", calendar.getAlos16Point1Degrees());
+ assertInstant("alos72", "2017-10-17T09:57:51.403184642Z", calendar.getAlos72Minutes());
+ }
+
+ @Test
+ public void sofZmanShmaAndTfila() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ assertInstant("sofZmanShmaGRA", "2017-10-17T13:55:53.352746510Z", calendar.getSofZmanShmaGRA());
+ assertInstant("sofZmanShmaMGA72", "2017-10-17T13:19:53.352746510Z", calendar.getSofZmanShmaMGA72Minutes());
+ assertInstant("sofZmanTfilaGRA", "2017-10-17T14:51:14.002600466Z", calendar.getSofZmanTfilaGRA());
+ assertInstant("sofZmanTfilaMGA72", "2017-10-17T14:27:14.002600466Z", calendar.getSofZmanTfilaMGA72Minutes());
+ }
+
+ @Test
+ public void chatzos() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ assertInstant("chatzosHayom", "2017-10-17T16:42:12.781249470Z", calendar.getChatzosHayom());
+ assertInstant("chatzosHayomAsHalfDay", "2017-10-17T16:41:55.302308378Z", calendar.getChatzosHayomAsHalfDay());
+ assertInstant("chatzosHalayla", "2017-10-18T04:42:06.833038724Z", calendar.getChatzosHalayla());
+ }
+
+ @Test
+ public void minchaAndPlag() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ assertInstant("minchaGedolaGRA", "2017-10-17T17:09:35.627235356Z", calendar.getMinchaGedolaGRA());
+ assertInstant("minchaKetanaGRA", "2017-10-17T19:55:37.576797224Z", calendar.getMinchaKetanaGRA());
+ assertInstant("plagHaminchaGRA", "2017-10-17T21:04:48.389114669Z", calendar.getPlagHaminchaGRA());
+ }
+
+ @Test
+ public void candleLightingAndTzais() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ assertInstant("candleLighting", "2017-10-17T21:55:59.201432122Z", calendar.getCandleLighting());
+ assertInstant("tzais72", "2017-10-17T23:25:59.201432122Z", calendar.getTzais72Minutes());
+ assertInstant("tzaisGeonim8.5", "2017-10-17T22:54:29.772724455Z", calendar.getTzaisGeonim8Point5Degrees());
+ }
+
+ @Test
+ public void shaahZmanis() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ assertEquals("shaahZmanisGRA", Duration.parse("PT55M20.649853956S"), calendar.getShaahZmanisGRA());
+ assertEquals("shaahZmanis72", Duration.parse("PT1H7M20.649853956S"), calendar.getShaahZmanis72Minutes());
+ }
+
+ /**
+ * Toggling {@link ZmanimCalendar#setUseElevation(boolean)} must change the sunrise/sunset-based zmanim, since the
+ * elevation adjustment shifts the underlying sunrise. Written relative to the default so it does not depend on what
+ * that default currently is.
+ */
+ @Test
+ public void elevationToggleAffectsZmanim() {
+ ZmanimCalendar calendar = fixtureCalendar();
+ boolean defaultSetting = calendar.isUseElevation();
+ Instant atDefault = calendar.getSofZmanShmaGRA();
+
+ calendar.setUseElevation(!defaultSetting);
+ Instant toggled = calendar.getSofZmanShmaGRA();
+
+ assertNotEquals(atDefault, toggled);
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/hebrewcalendar/JewishCalendarHolidayTest.java b/src/test/java/com/kosherjava/zmanim/hebrewcalendar/JewishCalendarHolidayTest.java
new file mode 100644
index 00000000..abd7090a
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/hebrewcalendar/JewishCalendarHolidayTest.java
@@ -0,0 +1,89 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.hebrewcalendar;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+/**
+ * Coverage for {@link JewishCalendar} holiday / special-day predicates and {@link HebrewDateFormatter#formatParsha}.
+ * These are deterministic halachic classifications asserted against known dates.
+ *
+ * @author Test coverage
+ */
+public class JewishCalendarHolidayTest {
+
+ @Test
+ public void roshHashanaAndTzomGedaliah() {
+ JewishCalendar date = new JewishCalendar(LocalDate.of(2023, 9, 16));
+ assertTrue(date.isRoshHashana());
+
+ date.plusDays(1);
+ assertTrue(date.isRoshHashana());
+
+ date.plusDays(1); // Tzom Gedaliah (3 Tishrei)
+ assertFalse(date.isRoshHashana());
+ assertTrue(date.isTaanis());
+ }
+
+ @Test
+ public void parsha() {
+ HebrewDateFormatter formatter = new HebrewDateFormatter();
+ assertEquals("Bereshis", formatter.formatParsha(new JewishCalendar(LocalDate.of(2023, 10, 14))));
+
+ HebrewDateFormatter hebrew = new HebrewDateFormatter();
+ hebrew.setHebrewFormat(true);
+ assertEquals("בראשית", hebrew.formatParsha(new JewishCalendar(LocalDate.of(2023, 10, 14))));
+
+ assertEquals("Terumah", formatter.formatParsha(new JewishCalendar(LocalDate.of(2024, 2, 17))));
+ }
+
+ @Test
+ public void purimAndShushanPurim() {
+ JewishCalendar date = new JewishCalendar();
+
+ // 14 Adar in a non-leap year: Purim in unwalled cities, but Shushan Purim (15th) in walled cities.
+ date.setJewishDate(5783, JewishDate.ADAR, 14);
+ assertTrue(date.isPurim());
+ date.setIsMukafChoma(true);
+ assertFalse(date.isPurim());
+
+ // 15 Adar: Shushan Purim, observed as Purim in walled cities.
+ date.plusDays(1);
+ assertTrue(date.isPurim());
+ date.setIsMukafChoma(false);
+ assertFalse(date.isPurim());
+
+ // In a leap year Purim falls in Adar II, so Adar (I) 14 is not Purim.
+ date.setJewishDate(5784, JewishDate.ADAR, 14);
+ assertFalse(date.isPurim());
+ }
+
+ @Test
+ public void assurBemelacha() {
+ // Yom Kippur (10 Tishrei) is assur bemelacha.
+ JewishCalendar yomKippur = new JewishCalendar(5784, JewishDate.TISHREI, 10);
+ assertTrue(yomKippur.isAssurBemelacha());
+
+ // An ordinary weekday is not.
+ JewishCalendar weekday = new JewishCalendar(5784, JewishDate.CHESHVAN, 5);
+ assertFalse(weekday.isAssurBemelacha());
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/hebrewcalendar/TefilaRulesTest.java b/src/test/java/com/kosherjava/zmanim/hebrewcalendar/TefilaRulesTest.java
new file mode 100644
index 00000000..fa1837c1
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/hebrewcalendar/TefilaRulesTest.java
@@ -0,0 +1,75 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.hebrewcalendar;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+/**
+ * Coverage for the {@link TefilaRules} daily-tefila predicates on two representative dates: an ordinary weekday during
+ * the summer, and the first day of Sukkos.
+ *
+ * @author Test coverage
+ */
+public class TefilaRulesTest {
+
+ private final TefilaRules rules = new TefilaRules();
+
+ @Test
+ public void ordinarySummerWeekday() {
+ JewishCalendar date = new JewishCalendar(LocalDate.of(2023, 8, 21));
+
+ assertTrue(rules.isTachanunRecitedShacharis(date));
+ assertTrue(rules.isTachanunRecitedMincha(date));
+ assertFalse(rules.isVeseinTalUmatarStartDate(date));
+ assertFalse(rules.isVeseinTalUmatarStartingTonight(date));
+ assertFalse(rules.isVeseinTalUmatarRecited(date));
+ assertTrue(rules.isVeseinBerachaRecited(date));
+ assertFalse(rules.isMashivHaruachStartDate(date));
+ assertFalse(rules.isMashivHaruachEndDate(date));
+ assertFalse(rules.isMashivHaruachRecited(date));
+ assertTrue(rules.isMoridHatalRecited(date));
+ assertFalse(rules.isHallelRecited(date));
+ assertFalse(rules.isHallelShalemRecited(date));
+ assertFalse(rules.isAlHanissimRecited(date));
+ assertFalse(rules.isYaalehVeyavoRecited(date));
+ assertTrue(rules.isMizmorLesodaRecited(date));
+ }
+
+ @Test
+ public void firstDayOfSukkos() {
+ JewishCalendar date = new JewishCalendar(LocalDate.of(2023, 10, 7));
+
+ assertFalse(rules.isTachanunRecitedShacharis(date));
+ assertFalse(rules.isTachanunRecitedMincha(date));
+ assertFalse(rules.isVeseinTalUmatarStartDate(date));
+ assertFalse(rules.isVeseinTalUmatarStartingTonight(date));
+ assertFalse(rules.isVeseinTalUmatarRecited(date));
+ assertTrue(rules.isVeseinBerachaRecited(date));
+ assertTrue(rules.isMashivHaruachStartDate(date));
+ assertFalse(rules.isMashivHaruachEndDate(date));
+ assertFalse(rules.isMashivHaruachRecited(date));
+ assertTrue(rules.isMoridHatalRecited(date));
+ assertTrue(rules.isHallelRecited(date));
+ assertTrue(rules.isHallelShalemRecited(date));
+ assertFalse(rules.isAlHanissimRecited(date));
+ assertTrue(rules.isYaalehVeyavoRecited(date));
+ assertFalse(rules.isMizmorLesodaRecited(date));
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/util/AstronomicalCalculatorTest.java b/src/test/java/com/kosherjava/zmanim/util/AstronomicalCalculatorTest.java
new file mode 100644
index 00000000..adbb8919
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/util/AstronomicalCalculatorTest.java
@@ -0,0 +1,147 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+/**
+ * Coverage for the {@link AstronomicalCalculator} abstract base: the pluggable-calculator contract ({@code getDefault},
+ * naming, {@code equals}/{@code clone}), the refraction / solar-radius / earth-radius configuration, the date-based
+ * {@link AstronomicalCalculator#getApparentSolarRadius(LocalDate) apparent solar radius}, and the shared degree-based
+ * trig helpers. The trig helpers are Tier-A checks verified against {@link Math}; this test lives in the
+ * {@code util} package so it can reach those {@code protected static} helpers.
+ *
+ * @author Test coverage
+ */
+public class AstronomicalCalculatorTest {
+
+ @Test
+ public void getDefaultReturnsNoaaCalculator() {
+ AstronomicalCalculator calculator = AstronomicalCalculator.getDefault();
+ assertTrue(calculator instanceof NOAACalculator);
+ }
+
+ @Test
+ public void calculatorNames() {
+ assertEquals("US National Oceanic and Atmospheric Administration Algorithm",
+ new NOAACalculator().getCalculatorName());
+ assertEquals("US Naval Almanac Algorithm", new SunTimesCalculator().getCalculatorName());
+ assertEquals("Jean Meeus Higher-Accuracy (VSOP87) Algorithm", new MeeusCalculator().getCalculatorName());
+ assertEquals("NREL Solar Position Algorithm", new SPACalculator().getCalculatorName());
+ }
+
+ @Test
+ public void defaultConfiguration() {
+ AstronomicalCalculator calculator = new NOAACalculator();
+ assertEquals(34 / 60d, calculator.getRefraction(), 0);
+ assertEquals(16 / 60d, calculator.getSolarRadius(), 0);
+ assertEquals(6371.0088, calculator.getEarthRadius(), 0);
+ assertTrue(calculator.isUseApparentSolarRadius());
+ }
+
+ @Test
+ public void refractionAndEarthRadiusSetters() {
+ AstronomicalCalculator calculator = new NOAACalculator();
+ calculator.setRefraction(0.5);
+ calculator.setEarthRadius(6371.0);
+ assertEquals(0.5, calculator.getRefraction(), 0);
+ assertEquals(6371.0, calculator.getEarthRadius(), 0);
+ }
+
+ @Test
+ public void setSolarRadiusDisablesApparentRadius() {
+ AstronomicalCalculator calculator = new NOAACalculator();
+ calculator.setSolarRadius(16 / 60d);
+ assertEquals(16 / 60d, calculator.getSolarRadius(), 0);
+ assertFalse(calculator.isUseApparentSolarRadius());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setSolarRadiusRejectsNegative() {
+ new NOAACalculator().setSolarRadius(-1);
+ }
+
+ @Test
+ public void apparentSolarRadius() {
+ AstronomicalCalculator calculator = new NOAACalculator();
+ // null date falls back to the fixed 16' radius
+ assertEquals(16 / 60d, calculator.getApparentSolarRadius(null), 0);
+ // table is keyed by day-of-year against the (common-year) reference year 2050
+ assertEquals(0.27108024, calculator.getApparentSolarRadius(LocalDate.of(2050, 1, 1)), 1e-8);
+ // the perihelion (~Jan 3) is near the annual maximum, the aphelion (~Jul 5) near the minimum
+ assertTrue(calculator.getApparentSolarRadius(LocalDate.of(2020, 1, 3))
+ > calculator.getApparentSolarRadius(LocalDate.of(2020, 7, 5)));
+ // Feb 29 resolves to Feb 28's value via withYear(2050)
+ assertEquals(calculator.getApparentSolarRadius(LocalDate.of(2020, 2, 28)),
+ calculator.getApparentSolarRadius(LocalDate.of(2020, 2, 29)), 0);
+ }
+
+ @Test
+ public void equalsAndHashCode() {
+ AstronomicalCalculator a = new NOAACalculator();
+ AstronomicalCalculator b = new NOAACalculator();
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+
+ b.setRefraction(0.5);
+ assertNotEquals(a, b);
+
+ // different concrete classes with identical settings are not equal
+ assertNotEquals(new NOAACalculator(), new SunTimesCalculator());
+ }
+
+ @Test
+ public void cloneProducesEqualIndependentCopy() {
+ AstronomicalCalculator original = new NOAACalculator();
+ AstronomicalCalculator clone = original.clone();
+ assertNotSame(original, clone);
+ assertEquals(original, clone);
+
+ clone.setRefraction(0.1);
+ assertNotEquals(original, clone);
+ }
+
+ /*
+ * Tier-A: the degree-based trig helpers must agree with java.lang.Math. They are protected static, reachable here
+ * because this test shares the com.kosherjava.zmanim.util package.
+ */
+ @Test
+ public void degreeTrigHelpersMatchMath() {
+ double[] angles = {0, 30, 45, 60, 90, 123.456, -17.5, 200};
+ for (double angle : angles) {
+ assertEquals("sin " + angle, Math.sin(Math.toRadians(angle)),
+ AstronomicalCalculator.sinDegrees(angle), 1e-12);
+ assertEquals("cos " + angle, Math.cos(Math.toRadians(angle)),
+ AstronomicalCalculator.cosDegrees(angle), 1e-12);
+ assertEquals("tan " + angle, Math.tan(Math.toRadians(angle)),
+ AstronomicalCalculator.tanDegrees(angle), 1e-9);
+ }
+ double[] ratios = {-1, -0.5, 0, 0.25, 0.5, 1};
+ for (double ratio : ratios) {
+ assertEquals("asin " + ratio, Math.toDegrees(Math.asin(ratio)),
+ AstronomicalCalculator.asinDegrees(ratio), 1e-12);
+ assertEquals("acos " + ratio, Math.toDegrees(Math.acos(ratio)),
+ AstronomicalCalculator.acosDegrees(ratio), 1e-12);
+ }
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/util/GeoLocationTest.java b/src/test/java/com/kosherjava/zmanim/util/GeoLocationTest.java
new file mode 100644
index 00000000..8d489c9e
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/util/GeoLocationTest.java
@@ -0,0 +1,197 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+import java.time.ZoneId;
+
+import org.junit.Test;
+
+/**
+ * Coverage for {@link GeoLocation}. The geodesic (Vincenty) and rhumb-line results are a Tier-A independently
+ * verifiable anchor: the coordinates are the classic Ordnance Survey reference points (Cornwall and Scotland). The
+ * rest is regression / behavioral coverage of construction, validation, the local-mean-time / antimeridian helpers, and
+ * {@code equals}/{@code hashCode}/{@code clone}/{@code toXML}.
+ *
+ * @author Test coverage
+ */
+public class GeoLocationTest {
+
+ private static final ZoneId NY = ZoneId.of("America/New_York");
+ private static final Instant JAN_1_2017 = Instant.parse("2017-01-01T00:00:00Z");
+
+ @Test
+ public void constructWithDefaults() {
+ GeoLocation geoLocation = new GeoLocation();
+ assertEquals(51.4772, geoLocation.getLatitude(), 0);
+ assertEquals(0.0, geoLocation.getLongitude(), 0);
+ assertEquals(ZoneId.of("GMT"), geoLocation.getZoneId());
+ assertEquals(0.0, geoLocation.getElevation(), 0);
+ assertEquals("Greenwich, England", geoLocation.getLocationName());
+ }
+
+ @Test
+ public void constructWithData() {
+ GeoLocation geoLocation = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ assertEquals(40.0828, geoLocation.getLatitude(), 0);
+ assertEquals(-74.2094, geoLocation.getLongitude(), 0);
+ assertEquals(NY, geoLocation.getZoneId());
+ assertEquals(20.0, geoLocation.getElevation(), 0);
+ assertEquals("Lakewood, NJ", geoLocation.getLocationName());
+ }
+
+ @Test
+ public void settersUpdateValues() {
+ GeoLocation geoLocation = new GeoLocation();
+ geoLocation.setLatitude(41.1181036);
+ geoLocation.setLongitude(-74.2094);
+ geoLocation.setZoneId(NY);
+ geoLocation.setElevation(20);
+ geoLocation.setLocationName("Lakewood, NJ");
+
+ assertEquals(41.1181036, geoLocation.getLatitude(), 0);
+ assertEquals(-74.2094, geoLocation.getLongitude(), 0);
+ assertEquals(NY, geoLocation.getZoneId());
+ assertEquals(20.0, geoLocation.getElevation(), 0);
+ assertEquals("Lakewood, NJ", geoLocation.getLocationName());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setLatitudeRejectsOutOfRange() {
+ new GeoLocation().setLatitude(90.1);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setLatitudeRejectsNaN() {
+ new GeoLocation().setLatitude(Double.NaN);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setLongitudeRejectsOutOfRange() {
+ new GeoLocation().setLongitude(-180.1);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setLongitudeRejectsNaN() {
+ new GeoLocation().setLongitude(Double.NaN);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setElevationRejectsNegative() {
+ new GeoLocation().setElevation(-1);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setElevationRejectsInfinite() {
+ new GeoLocation().setElevation(Double.POSITIVE_INFINITY);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void constructorValidatesLatitude() {
+ new GeoLocation("bad", 100, 0, ZoneId.of("GMT"));
+ }
+
+ @Test
+ public void getLocalMeanTimeOffset() {
+ assertEquals(0L, new GeoLocation().getLocalMeanTimeOffset(JAN_1_2017));
+
+ GeoLocation lakewood = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ assertEquals(189744L, lakewood.getLocalMeanTimeOffset(JAN_1_2017));
+ }
+
+ @Test
+ public void antimeridianAdjustment() {
+ assertEquals(0, new GeoLocation().getAntimeridianAdjustment(JAN_1_2017));
+
+ GeoLocation ny = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ assertEquals(0, ny.getAntimeridianAdjustment(JAN_1_2017));
+
+ // Apia, Samoa (UTC+14 in January) is well east of its longitude-implied zone: roll the date back a day.
+ GeoLocation apia = new GeoLocation("Apia, Samoa", -13.8599098, -171.8031745, 1858, ZoneId.of("Pacific/Apia"));
+ assertEquals(-1, apia.getAntimeridianAdjustment(JAN_1_2017));
+
+ // A location east of the dateline forced onto a UTC-12 zone: roll the date forward a day.
+ GeoLocation westOfLine = new GeoLocation("GMT +12", -13.8599098, 179, 0, ZoneId.of("Etc/GMT+12"));
+ assertEquals(1, westOfLine.getAntimeridianAdjustment(JAN_1_2017));
+ }
+
+ /*
+ * Tier-A: Vincenty and rhumb-line results against the classic Ordnance Survey reference points.
+ */
+ @Test
+ public void vincentyFormulae() {
+ GeoLocation pointA = new GeoLocation("A", 50.06632222222222, -5.71475, ZoneId.of("Etc/GMT+12"));
+ GeoLocation pointB = new GeoLocation("B", 58.64402222222222, -3.0700944444444445, ZoneId.of("Etc/GMT+12"));
+
+ assertEquals(9.14186191, pointA.getGeodesicInitialBearing(pointB), 1e-8);
+ assertEquals(11.29720127, pointA.getGeodesicFinalBearing(pointB), 1e-8);
+ assertEquals(969954.11445043, pointA.getGeodesicDistance(pointB), 1e-5);
+ }
+
+ @Test
+ public void geodesicDistanceForCoincidentPointsIsZero() {
+ GeoLocation point = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, NY);
+ assertEquals(0.0, point.getGeodesicDistance(point), 0);
+ }
+
+ @Test
+ public void rhumbLine() {
+ GeoLocation pointA = new GeoLocation("A", 50.06638888888889, -5.714722222222222, ZoneId.of("Etc/GMT+12"));
+ GeoLocation pointB = new GeoLocation("B", 58.64388888888889, -3.07, ZoneId.of("Etc/GMT+12"));
+
+ assertEquals(10.14069288, pointA.getRhumbLineBearing(pointB), 1e-8);
+ assertEquals(969995.8368008, pointA.getRhumbLineDistance(pointB), 1e-5);
+ }
+
+ @Test
+ public void equalsAndHashCode() {
+ GeoLocation a = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ GeoLocation b = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+
+ GeoLocation c = new GeoLocation("Lakewood, NJ", 41.0, -74.2094, 20, NY);
+ assertNotEquals(a, c);
+ }
+
+ @Test
+ public void cloneProducesIndependentCopy() {
+ GeoLocation geoLocation = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ GeoLocation copy = (GeoLocation) geoLocation.clone();
+
+ assertNotSame(geoLocation, copy);
+ assertEquals(geoLocation, copy);
+
+ copy.setLatitude(10);
+ assertEquals(40.0828, geoLocation.getLatitude(), 0);
+ assertEquals(10.0, copy.getLatitude(), 0);
+ }
+
+ @Test
+ public void toXmlContainsFields() {
+ GeoLocation geoLocation = new GeoLocation("Lakewood, NJ", 40.0828, -74.2094, 20, NY);
+ String xml = geoLocation.toXML();
+ assertTrue(xml.contains("Lakewood, NJ"));
+ assertTrue(xml.contains("40.0828"));
+ assertTrue(xml.contains("-74.2094"));
+ assertTrue(xml.contains("20.0 Meters"));
+ assertTrue(xml.contains("America/New_York"));
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/util/MeeusCalculatorTest.java b/src/test/java/com/kosherjava/zmanim/util/MeeusCalculatorTest.java
new file mode 100644
index 00000000..cfb14f01
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/util/MeeusCalculatorTest.java
@@ -0,0 +1,256 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+
+import org.junit.Test;
+
+import com.kosherjava.zmanim.TestLocations;
+
+/**
+ * Regression coverage for {@link MeeusCalculator}, the higher-accuracy Jean Meeus (VSOP87) engine with ΔT
+ * support. Expected values are the library's own current output (defaults, ΔT on) asserted to 8 decimals, plus a
+ * ΔT-disabled variant via {@link MeeusCalculator#setApplyDeltaT(boolean)}.
+ *
+ * @author Test coverage
+ */
+public class MeeusCalculatorTest {
+
+ private static final double DELTA = 1e-8;
+ private static final double ZENITH = 90.0;
+ private static final double NO_EVENT = Double.NaN;
+
+ private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private MeeusCalculator calculator() {
+ return new MeeusCalculator();
+ }
+
+ private GeoLocation geo(String location) {
+ switch (location) {
+ case "NJ": return TestLocations.nj(TestLocations.UTC);
+ case "LA": return TestLocations.la(TestLocations.UTC);
+ case "JERUSALEM": return TestLocations.jerusalem(TestLocations.UTC);
+ case "NORWAY": return TestLocations.norway(TestLocations.UTC);
+ case "SYDNEY": return TestLocations.sydney(TestLocations.UTC);
+ case "MACAPA": return TestLocations.macapa(TestLocations.UTC);
+ case "SUVA": return TestLocations.suva(TestLocations.UTC);
+ case "USHUAIA": return TestLocations.ushuaia(TestLocations.UTC);
+ default: throw new IllegalArgumentException("Unknown location: " + location);
+ }
+ }
+
+ private LocalDate date(String iso) {
+ return LocalDate.parse(iso);
+ }
+
+ private Instant instant(String isoDateTime) {
+ return LocalDateTime.parse(isoDateTime, DATE_TIME).toInstant(ZoneOffset.UTC);
+ }
+
+ private void assertUtcHour(String label, double expected, double actual) {
+ if (Double.isNaN(expected)) {
+ assertTrue(label + " should have no event (NaN)", Double.isNaN(actual));
+ } else {
+ assertEquals(label, expected, actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunrise() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 11.13565507},
+ {"LA Standard Date", "2017-10-17", "LA", 14.00730639},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 4.11849856},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 19.68266925},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 9.38974837},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 18.60510228},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 12.95697826},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 9.44873166},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunset() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 22.24115497},
+ {"LA Standard Date", "2017-10-17", "LA", 1.31857880},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 15.64527430},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 8.56381339},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 21.53114700},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 5.65803778},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 20.21223110},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 0.58039250},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunset(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCNoon() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 16.69344845},
+ {"LA Standard Date", "2017-10-17", "LA", 19.66689969},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 9.87812628},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", 11.42050168},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 2.12793408},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 15.46045983},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 0.13159547},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 16.58453220},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 17.01674900},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCNoon(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCMidnight() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 4.69183111},
+ {"LA Standard Date", "2017-10-17", "LA", 7.66529295},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 21.87675832},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", 23.42232487},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 14.12636235},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 3.46440271},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 12.13342359},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 4.58635433},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 5.01821298},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCMidnight(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getTimeAtAzimuth() {
+ Object[][] cases = {
+ {"NJ East", "2017-10-17", "NJ", 90.0, 9.96630242},
+ {"NJ West", "2017-10-17", "NJ", 270.0, 23.43696025},
+ {"LA East", "2017-10-17", "LA", 90.0, 12.71802243},
+ {"LA West", "2017-10-17", "LA", 270.0, 2.63792382},
+ {"Sydney East", "2020-02-29", "SYDNEY", 90.0, 20.92601400},
+ {"Sydney West", "2020-02-29", "SYDNEY", 270.0, 7.34685529},
+ {"Macapa East No Cross", "2000-01-01", "MACAPA", 90.0, NO_EVENT},
+ {"Polar East", "2017-06-21", "NORWAY", 90.0, 6.01986159},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getTimeAtAzimuth(date((String) c[1]), geo((String) c[2]), (double) c[3]);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getSolarElevation() {
+ Object[][] cases = {
+ {"NJ Noon", "2017-10-17 12:00:00", "NJ", 8.24234881},
+ {"NJ Evening", "2017-10-17 18:30:00", "NJ", 33.57213757},
+ {"LA Evening", "2017-10-17 20:00:00", "LA", 46.19473579},
+ {"Polar Midnight", "2017-06-21 00:00:00", "NORWAY", 3.98711013},
+ {"Sydney Morning", "2020-02-29 20:00:00", "SYDNEY", 2.89445709},
+ {"Macapa Local Noon", "2000-01-01 15:00:00", "MACAPA", 65.99208585},
+ {"Suva Afternoon", "2023-06-21 02:00:00", "SUVA", 40.24688524},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getSolarElevation(instant((String) c[1]), geo((String) c[2]));
+ assertEquals((String) c[0], (double) c[3], actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getSolarAzimuth() {
+ Object[][] cases = {
+ {"NJ Noon", "2017-10-17 12:00:00", "NJ", 110.13820751},
+ {"NJ Evening", "2017-10-17 18:30:00", "NJ", 212.62255322},
+ {"LA Evening", "2017-10-17 20:00:00", "LA", 187.12661462},
+ {"Polar Midnight", "2017-06-21 00:00:00", "NORWAY", 8.01211355},
+ {"Sydney Morning", "2020-02-29 20:00:00", "SYDNEY", 97.32257044},
+ {"Macapa Local Noon", "2000-01-01 15:00:00", "MACAPA", 164.22488663},
+ {"Suva Afternoon", "2023-06-21 02:00:00", "SUVA", 325.62695543},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getSolarAzimuth(instant((String) c[1]), geo((String) c[2]));
+ assertEquals((String) c[0], (double) c[3], actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseAtZenith() {
+ Object[][] cases = {
+ {"NJ Civil", "2017-10-17", "NJ", 96.0, 10.70916170},
+ {"NJ Nautical", "2017-10-17", "NJ", 102.0, 10.17586674},
+ {"NJ Astronomical", "2017-10-17", "NJ", 108.0, 9.64430764},
+ {"Sydney Civil", "2020-02-29", "SYDNEY", 96.0, 19.27848095},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), (double) c[3], true);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseWithoutElevationAdjustment() {
+ Object[][] cases = {
+ {"NJ No Elev Adj", "2017-10-17", "NJ", 11.17316120},
+ {"Jerusalem No Elev Adj", "1955-02-26", "JERUSALEM", 4.18861444},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, false);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseWithDeltaTDisabled() {
+ Object[][] cases = {
+ {"NJ dT off", "2017-10-17", "NJ", 11.13563996},
+ {"Jerusalem dT off", "1955-02-26", "JERUSALEM", 4.11850671},
+ };
+ for (Object[] c : cases) {
+ MeeusCalculator calc = calculator();
+ calc.setApplyDeltaT(false);
+ double actual = calc.getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void getTimeAtAzimuthRejectsUnsupportedAzimuth() {
+ calculator().getTimeAtAzimuth(date("2017-10-17"), geo("NJ"), 123.0);
+ }
+
+ @Test
+ public void getCalculatorName() {
+ assertEquals("Jean Meeus Higher-Accuracy (VSOP87) Algorithm", calculator().getCalculatorName());
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/util/NOAACalculatorTest.java b/src/test/java/com/kosherjava/zmanim/util/NOAACalculatorTest.java
new file mode 100644
index 00000000..d18c4626
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/util/NOAACalculatorTest.java
@@ -0,0 +1,242 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+
+import org.junit.Test;
+
+import com.kosherjava.zmanim.TestLocations;
+
+/**
+ * Regression coverage for {@link NOAACalculator}, the library's default sunrise/sunset engine. Expected values are the
+ * library's own current output asserted to 8 decimals. Cases span both hemispheres, the equator, the dateline, a polar
+ * no-event, a leap day, a year boundary and a future ΔT date. Each test iterates a labeled case table.
+ *
+ * @author Test coverage
+ */
+public class NOAACalculatorTest {
+
+ private static final double DELTA = 1e-8;
+ private static final double ZENITH = 90.0; // AstronomicalCalculator.GEOMETRIC_ZENITH
+ private static final double NO_EVENT = Double.NaN;
+
+ private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private NOAACalculator calculator() {
+ return new NOAACalculator();
+ }
+
+ private GeoLocation geo(String location) {
+ switch (location) {
+ case "NJ": return TestLocations.nj(TestLocations.UTC);
+ case "LA": return TestLocations.la(TestLocations.UTC);
+ case "JERUSALEM": return TestLocations.jerusalem(TestLocations.UTC);
+ case "NORWAY": return TestLocations.norway(TestLocations.UTC);
+ case "SYDNEY": return TestLocations.sydney(TestLocations.UTC);
+ case "MACAPA": return TestLocations.macapa(TestLocations.UTC);
+ case "SUVA": return TestLocations.suva(TestLocations.UTC);
+ case "USHUAIA": return TestLocations.ushuaia(TestLocations.UTC);
+ default: throw new IllegalArgumentException("Unknown location: " + location);
+ }
+ }
+
+ private LocalDate date(String iso) {
+ return LocalDate.parse(iso);
+ }
+
+ private Instant instant(String isoDateTime) {
+ return LocalDateTime.parse(isoDateTime, DATE_TIME).toInstant(ZoneOffset.UTC);
+ }
+
+ private void assertUtcHour(String label, double expected, double actual) {
+ if (Double.isNaN(expected)) {
+ assertTrue(label + " should have no event (NaN)", Double.isNaN(actual));
+ } else {
+ assertEquals(label, expected, actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunrise() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 11.13540831},
+ {"LA Standard Date", "2017-10-17", "LA", 14.00704301},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 4.11872483},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 19.68246214},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 9.39008078},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 18.60488873},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 12.95743787},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 9.44903860},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunset() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 22.24081501},
+ {"LA Standard Date", "2017-10-17", "LA", 1.31823827},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 15.64544001},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 8.56373515},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 21.53146217},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 5.65781259},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 20.21256620},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 0.58065990},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunset(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCNoon() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 16.69315478},
+ {"LA Standard Date", "2017-10-17", "LA", 19.66659743},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 9.87832210},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", 11.42090460},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 2.12779094},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 15.46078241},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 0.13137581},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 16.58492880},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 17.01703523},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCNoon(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCMidnight() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 4.69150247},
+ {"LA Standard Date", "2017-10-17", "LA", 7.66495567},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 21.87692914},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", 23.42271259},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 14.12619634},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 3.46470789},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 12.13318970},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 4.58673539},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 5.01848806},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCMidnight(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getTimeAtAzimuth() {
+ Object[][] cases = {
+ {"NJ East", "2017-10-17", "NJ", 90.0, 9.96599151},
+ {"NJ West", "2017-10-17", "NJ", 270.0, 23.43667951},
+ {"LA East", "2017-10-17", "LA", 90.0, 12.71769344},
+ {"LA West", "2017-10-17", "LA", 270.0, 2.63764230},
+ {"Sydney East", "2020-02-29", "SYDNEY", 90.0, 20.92605878},
+ {"Sydney West", "2020-02-29", "SYDNEY", 270.0, 7.34651923},
+ {"Macapa East No Cross", "2000-01-01", "MACAPA", 90.0, NO_EVENT},
+ {"Polar East", "2017-06-21", "NORWAY", 90.0, 6.02028371},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getTimeAtAzimuth(date((String) c[1]), geo((String) c[2]), (double) c[3]);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getSolarElevation() {
+ Object[][] cases = {
+ {"NJ Noon", "2017-10-17 12:00:00", "NJ", 8.24496867},
+ {"NJ Evening", "2017-10-17 18:30:00", "NJ", 33.56992906},
+ {"LA Evening", "2017-10-17 20:00:00", "LA", 46.19384884},
+ {"Polar Midnight", "2017-06-21 00:00:00", "NORWAY", 3.98724677},
+ {"Sydney Morning", "2020-02-29 20:00:00", "SYDNEY", 2.89748529},
+ {"Macapa Local Noon", "2000-01-01 15:00:00", "MACAPA", 65.99072692},
+ {"Suva Afternoon", "2023-06-21 02:00:00", "SUVA", 40.24513182},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getSolarElevation(instant((String) c[1]), geo((String) c[2]));
+ assertEquals((String) c[0], (double) c[3], actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getSolarAzimuth() {
+ Object[][] cases = {
+ {"NJ Noon", "2017-10-17 12:00:00", "NJ", 110.14145579},
+ {"NJ Evening", "2017-10-17 18:30:00", "NJ", 212.62717267},
+ {"LA Evening", "2017-10-17 20:00:00", "LA", 187.13298966},
+ {"Polar Midnight", "2017-06-21 00:00:00", "NORWAY", 8.00635444},
+ {"Sydney Morning", "2020-02-29 20:00:00", "SYDNEY", 97.32262775},
+ {"Macapa Local Noon", "2000-01-01 15:00:00", "MACAPA", 164.21440268},
+ {"Suva Afternoon", "2023-06-21 02:00:00", "SUVA", 325.62368833},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getSolarAzimuth(instant((String) c[1]), geo((String) c[2]));
+ assertEquals((String) c[0], (double) c[3], actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseAtZenith() {
+ Object[][] cases = {
+ {"NJ Civil", "2017-10-17", "NJ", 96.0, 10.70891556},
+ {"NJ Nautical", "2017-10-17", "NJ", 102.0, 10.17562194},
+ {"NJ Astronomical", "2017-10-17", "NJ", 108.0, 9.64406486},
+ {"Sydney Civil", "2020-02-29", "SYDNEY", 96.0, 19.27827196},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), (double) c[3], true);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseWithoutElevationAdjustment() {
+ Object[][] cases = {
+ {"NJ No Elev Adj", "2017-10-17", "NJ", 11.17291440},
+ {"Jerusalem No Elev Adj", "1955-02-26", "JERUSALEM", 4.18884065},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, false);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void getTimeAtAzimuthRejectsUnsupportedAzimuth() {
+ calculator().getTimeAtAzimuth(date("2017-10-17"), geo("NJ"), 123.0);
+ }
+
+ @Test
+ public void getCalculatorName() {
+ assertEquals("US National Oceanic and Atmospheric Administration Algorithm", calculator().getCalculatorName());
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java b/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java
new file mode 100644
index 00000000..18cc1357
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/util/SPACalculatorTest.java
@@ -0,0 +1,311 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+
+import org.junit.Test;
+
+import com.kosherjava.zmanim.TestLocations;
+
+/**
+ * Coverage for {@link SPACalculator}, the NREL Solar Position Algorithm engine. Most cases are regression against the
+ * library's own current output (8-decimal delta), but this suite also includes a Tier-A independently verifiable
+ * anchor: the published NREL reference case (Reda & Andreas, NREL/TP-560-34302), plus coverage of the
+ * pressure/temperature/ΔT configuration knobs.
+ *
+ * @author Test coverage
+ */
+public class SPACalculatorTest {
+
+ private static final double DELTA = 1e-8;
+ private static final double ZENITH = 90.0;
+ private static final double NO_EVENT = Double.NaN;
+
+ private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private SPACalculator calculator() {
+ return new SPACalculator();
+ }
+
+ private GeoLocation geo(String location) {
+ switch (location) {
+ case "NJ": return TestLocations.nj(TestLocations.UTC);
+ case "LA": return TestLocations.la(TestLocations.UTC);
+ case "JERUSALEM": return TestLocations.jerusalem(TestLocations.UTC);
+ case "NORWAY": return TestLocations.norway(TestLocations.UTC);
+ case "SYDNEY": return TestLocations.sydney(TestLocations.UTC);
+ case "MACAPA": return TestLocations.macapa(TestLocations.UTC);
+ case "SUVA": return TestLocations.suva(TestLocations.UTC);
+ case "USHUAIA": return TestLocations.ushuaia(TestLocations.UTC);
+ default: throw new IllegalArgumentException("Unknown location: " + location);
+ }
+ }
+
+ private LocalDate date(String iso) {
+ return LocalDate.parse(iso);
+ }
+
+ private Instant instant(String isoDateTime) {
+ return LocalDateTime.parse(isoDateTime, DATE_TIME).toInstant(ZoneOffset.UTC);
+ }
+
+ private void assertUtcHour(String label, double expected, double actual) {
+ if (Double.isNaN(expected)) {
+ assertTrue(label + " should have no event (NaN)", Double.isNaN(actual));
+ } else {
+ assertEquals(label, expected, actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunrise() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 11.13593689},
+ {"LA Standard Date", "2017-10-17", "LA", 14.00756754},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 4.11873932},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 19.68293143},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 9.38998685},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 18.60534965},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 12.95741199},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 9.44912615},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunset() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 22.24099415},
+ {"LA Standard Date", "2017-10-17", "LA", 1.31843857},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 15.64512300},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 8.56367402},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 21.53102552},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 5.65791427},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 20.21191830},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 0.58028674},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunset(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCNoon() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 16.69350885},
+ {"LA Standard Date", "2017-10-17", "LA", 19.66696009},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 9.87817106},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", 11.42056214},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 2.12799525},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 15.46051831},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 0.13165740},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 16.58459266},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 17.01689317},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCNoon(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCMidnight() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 4.69189152},
+ {"LA Standard Date", "2017-10-17", "LA", 7.66535335},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 21.87680310},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", 23.42238534},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 14.12642351},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 3.46446119},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 12.13348552},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 4.58641480},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 5.01835716},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCMidnight(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getTimeAtAzimuth() {
+ Object[][] cases = {
+ {"NJ East", "2017-10-17", "NJ", 90.0, 9.96630382},
+ {"NJ West", "2017-10-17", "NJ", 270.0, 23.43696229},
+ {"LA East", "2017-10-17", "LA", 90.0, 12.71802376},
+ {"LA West", "2017-10-17", "LA", 270.0, 2.63792595},
+ {"Sydney East", "2020-02-29", "SYDNEY", 90.0, 20.92601636},
+ {"Sydney West", "2020-02-29", "SYDNEY", 270.0, 7.34685680},
+ {"Macapa East No Cross", "2000-01-01", "MACAPA", 90.0, NO_EVENT},
+ {"Polar East", "2017-06-21", "NORWAY", 90.0, 6.01986373},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getTimeAtAzimuth(date((String) c[1]), geo((String) c[2]), (double) c[3]);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getSolarElevation() {
+ Object[][] cases = {
+ {"NJ Noon", "2017-10-17 12:00:00", "NJ", 8.24142367},
+ {"NJ Evening", "2017-10-17 18:30:00", "NJ", 33.57165658},
+ {"LA Evening", "2017-10-17 20:00:00", "LA", 46.19391692},
+ {"Polar Midnight", "2017-06-21 00:00:00", "NORWAY", 3.98549481},
+ {"Sydney Morning", "2020-02-29 20:00:00", "SYDNEY", 2.89087872},
+ {"Macapa Local Noon", "2000-01-01 15:00:00", "MACAPA", 65.99118480},
+ {"Suva Afternoon", "2023-06-21 02:00:00", "SUVA", 40.24649656},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getSolarElevation(instant((String) c[1]), geo((String) c[2]));
+ assertEquals((String) c[0], (double) c[3], actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getSolarAzimuth() {
+ Object[][] cases = {
+ {"NJ Noon", "2017-10-17 12:00:00", "NJ", 110.13757333},
+ {"NJ Evening", "2017-10-17 18:30:00", "NJ", 212.62157928},
+ {"LA Evening", "2017-10-17 20:00:00", "LA", 187.12533185},
+ {"Polar Midnight", "2017-06-21 00:00:00", "NORWAY", 8.01127958},
+ {"Sydney Morning", "2020-02-29 20:00:00", "SYDNEY", 97.32308776},
+ {"Macapa Local Noon", "2000-01-01 15:00:00", "MACAPA", 164.22300003},
+ {"Suva Afternoon", "2023-06-21 02:00:00", "SUVA", 325.62786587},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getSolarAzimuth(instant((String) c[1]), geo((String) c[2]));
+ assertEquals((String) c[0], (double) c[3], actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseAtZenith() {
+ Object[][] cases = {
+ {"NJ Civil", "2017-10-17", "NJ", 96.0, 10.70943974},
+ {"NJ Nautical", "2017-10-17", "NJ", 102.0, 10.17613961},
+ {"NJ Astronomical", "2017-10-17", "NJ", 108.0, 9.64457480},
+ {"Sydney Civil", "2020-02-29", "SYDNEY", 96.0, 19.27874463},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), (double) c[3], true);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseWithoutElevationAdjustment() {
+ Object[][] cases = {
+ {"NJ No Elev Adj", "2017-10-17", "NJ", 11.17344334},
+ {"Jerusalem No Elev Adj", "1955-02-26", "JERUSALEM", 4.18885558},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, false);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseWithDeltaTDisabled() {
+ Object[][] cases = {
+ {"NJ dT off", "2017-10-17", "NJ", 11.13586818},
+ {"Jerusalem dT off", "1955-02-26", "JERUSALEM", 4.11871824},
+ };
+ for (Object[] c : cases) {
+ SPACalculator calc = calculator();
+ calc.setApplyDeltaT(false);
+ double actual = calc.getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void getTimeAtAzimuthRejectsUnsupportedAzimuth() {
+ calculator().getTimeAtAzimuth(date("2017-10-17"), geo("NJ"), 123.0);
+ }
+
+ /*
+ * Tier-A independently verifiable reference: the published NREL Solar Position Algorithm example
+ * (Reda & Andreas, NREL/TP-560-34302). 2003-10-17 12:30:30 local (tz -7 => 19:30:30 UTC),
+ * longitude -105.1786, latitude 39.742476, elevation 1830.14 m, pressure 820 mb, temperature 11 C,
+ * delta-T 67 s. The paper reports topocentric elevation 39.888378 and azimuth 194.340241.
+ */
+ private SPACalculator nrelCalculator() {
+ SPACalculator calc = new SPACalculator();
+ calc.setDeltaTOverride(67.0);
+ calc.setPressure(820);
+ calc.setTemperature(11);
+ return calc;
+ }
+
+ private Instant nrelInstant() {
+ return instant("2003-10-17 19:30:30");
+ }
+
+ private GeoLocation nrelLocation() {
+ return new GeoLocation("NREL Reference", 39.742476, -105.1786, 1830.14, TestLocations.UTC);
+ }
+
+ @Test
+ public void getSolarElevationNrelReference() {
+ double elevation = nrelCalculator().getSolarElevation(nrelInstant(), nrelLocation());
+ assertEquals("NREL published topocentric elevation", 39.888378, elevation, 0.001);
+ }
+
+ @Test
+ public void getSolarAzimuthNrelReference() {
+ double azimuth = nrelCalculator().getSolarAzimuth(nrelInstant(), nrelLocation());
+ assertEquals("NREL published topocentric azimuth", 194.340241, azimuth, 0.001);
+ }
+
+ @Test
+ public void defaultsAndSetters() {
+ SPACalculator calc = new SPACalculator();
+ assertTrue(calc.isApplyDeltaT());
+ assertNull(calc.getDeltaTOverride());
+ assertEquals(1013.25, calc.getPressure(), 0);
+ assertEquals(10.0, calc.getTemperature(), 0);
+
+ calc.setDeltaTOverride(67.0);
+ calc.setPressure(820);
+ calc.setTemperature(11);
+ calc.setApplyDeltaT(false);
+
+ assertEquals(Double.valueOf(67.0), calc.getDeltaTOverride());
+ assertEquals(820.0, calc.getPressure(), 0);
+ assertEquals(11.0, calc.getTemperature(), 0);
+ assertTrue(!calc.isApplyDeltaT());
+ }
+
+ @Test
+ public void getCalculatorName() {
+ assertEquals("NREL Solar Position Algorithm", calculator().getCalculatorName());
+ }
+}
diff --git a/src/test/java/com/kosherjava/zmanim/util/SunTimesCalculatorTest.java b/src/test/java/com/kosherjava/zmanim/util/SunTimesCalculatorTest.java
new file mode 100644
index 00000000..97f92e4d
--- /dev/null
+++ b/src/test/java/com/kosherjava/zmanim/util/SunTimesCalculatorTest.java
@@ -0,0 +1,192 @@
+/*
+ * Zmanim Java API
+ * Copyright © 2004-2026 Eliyahu Hershfeld
+ *
+ * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
+ * Public License as published by the Free Software Foundation; version 2.1 of the License.
+ *
+ * This library is distributed in the hope that it will be useful,but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA,
+ * or connect to: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
+ */
+package com.kosherjava.zmanim.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+import com.kosherjava.zmanim.TestLocations;
+
+/**
+ * Regression coverage for {@link SunTimesCalculator} (the older USNO / "Almanac for Computers" algorithm). Expected
+ * values are the library's own current output asserted to 8 decimals. Unlike {@link NOAACalculator}, this calculator
+ * returns {@link Double#NaN} for solar noon/midnight at the polar location and does not implement solar
+ * elevation/azimuth or time-at-azimuth (those throw {@link UnsupportedOperationException}).
+ *
+ * @author Test coverage
+ */
+public class SunTimesCalculatorTest {
+
+ private static final double DELTA = 1e-8;
+ private static final double ZENITH = 90.0;
+ private static final double NO_EVENT = Double.NaN;
+
+ private SunTimesCalculator calculator() {
+ return new SunTimesCalculator();
+ }
+
+ private GeoLocation geo(String location) {
+ switch (location) {
+ case "NJ": return TestLocations.nj(TestLocations.UTC);
+ case "LA": return TestLocations.la(TestLocations.UTC);
+ case "JERUSALEM": return TestLocations.jerusalem(TestLocations.UTC);
+ case "NORWAY": return TestLocations.norway(TestLocations.UTC);
+ case "SYDNEY": return TestLocations.sydney(TestLocations.UTC);
+ case "MACAPA": return TestLocations.macapa(TestLocations.UTC);
+ case "SUVA": return TestLocations.suva(TestLocations.UTC);
+ case "USHUAIA": return TestLocations.ushuaia(TestLocations.UTC);
+ default: throw new IllegalArgumentException("Unknown location: " + location);
+ }
+ }
+
+ private LocalDate date(String iso) {
+ return LocalDate.parse(iso);
+ }
+
+ private void assertUtcHour(String label, double expected, double actual) {
+ if (Double.isNaN(expected)) {
+ assertTrue(label + " should have no event (NaN)", Double.isNaN(actual));
+ } else {
+ assertEquals(label, expected, actual, DELTA);
+ }
+ }
+
+ @Test
+ public void getUTCSunrise() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 11.12643745},
+ {"LA Standard Date", "2017-10-17", "LA", 14.00076197},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 4.11037446},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 19.68724563},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 9.39464043},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 18.60351329},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 12.95527929},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 9.43972880},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunset() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 22.25383390},
+ {"LA Standard Date", "2017-10-17", "LA", 1.32889810},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 15.65101844},
+ {"Norway Polar Solstice", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 8.56114598},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 21.53593703},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 5.65641338},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 20.20896383},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 0.57971462},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunset(date((String) c[1]), geo((String) c[2]), ZENITH, true);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCNoon() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 16.69011947},
+ {"LA Standard Date", "2017-10-17", "LA", 19.66482200},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 9.88071908},
+ {"Norway Polar No Noon", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 2.12420286},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 15.46529012},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 0.12996327},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 16.58212152},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 17.00973685},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCNoon(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCMidnight() {
+ Object[][] cases = {
+ {"NJ Standard Date", "2017-10-17", "NJ", 4.69011947},
+ {"LA Standard Date", "2017-10-17", "LA", 7.66482200},
+ {"Jerusalem Hist. Date", "1955-02-26", "JERUSALEM", 21.88071908},
+ {"Norway Polar No Midnight", "2017-06-21", "NORWAY", NO_EVENT},
+ {"Sydney Leap Day", "2020-02-29", "SYDNEY", 14.12420286},
+ {"Macapa Equator New Year", "2000-01-01", "MACAPA", 3.46529012},
+ {"Suva Fiji Solstice", "2023-06-21", "SUVA", 12.12996327},
+ {"Ushuaia Southern Winter", "2017-06-21", "USHUAIA", 4.58212152},
+ {"NJ Future DeltaT", "2100-07-04", "NJ", 5.00973685},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCMidnight(date((String) c[1]), geo((String) c[2]));
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseAtZenith() {
+ Object[][] cases = {
+ {"NJ Civil", "2017-10-17", "NJ", 96.0, 10.70056986},
+ {"NJ Nautical", "2017-10-17", "NJ", 102.0, 10.16784691},
+ {"NJ Astronomical", "2017-10-17", "NJ", 108.0, 9.63665090},
+ {"Sydney Civil", "2020-02-29", "SYDNEY", 96.0, 19.28346936},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), (double) c[3], true);
+ assertUtcHour((String) c[0], (double) c[4], actual);
+ }
+ }
+
+ @Test
+ public void getUTCSunriseWithoutElevationAdjustment() {
+ Object[][] cases = {
+ {"NJ No Elev Adj", "2017-10-17", "NJ", 11.16388056},
+ {"Jerusalem No Elev Adj", "1955-02-26", "JERUSALEM", 4.18050398},
+ };
+ for (Object[] c : cases) {
+ double actual = calculator().getUTCSunrise(date((String) c[1]), geo((String) c[2]), ZENITH, false);
+ assertUtcHour((String) c[0], (double) c[3], actual);
+ }
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void getTimeAtAzimuthUnsupported() {
+ calculator().getTimeAtAzimuth(date("2017-10-17"), geo("NJ"), 90.0);
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void getSolarElevationUnsupported() {
+ calculator().getSolarElevation(Instant.parse("2017-10-17T12:00:00Z"), geo("NJ"));
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void getSolarAzimuthUnsupported() {
+ calculator().getSolarAzimuth(Instant.parse("2017-10-17T12:00:00Z"), geo("NJ"));
+ }
+
+ @Test
+ public void getCalculatorName() {
+ assertEquals("US Naval Almanac Algorithm", calculator().getCalculatorName());
+ }
+}