Skip to content

Commit 9971f4c

Browse files
committed
fix: prevent sunrise/sunset times being identical when Phase 3 convergence ends below threshold
The findSunRiseSet algorithm's Phase 3 converges on the sunrise elevation using a symmetric stepping approach. Due to the convergence step size, the final elevation can land slightly below the -0.8333° threshold (e.g. -0.838° instead of -0.8333°). When this happens, Phase 4's while-loop condition (sunPos.elevation > -threshold) evaluates to false immediately, causing it to skip entirely. Phase 5 then converges to the same point, producing identical sunrise and sunset times. The fix adds a small offset (daynum = sunrise + 0.001, ~1.4 minutes) at the start of Phase 4, ensuring the sun is clearly above the threshold before the sunset search begins. This is a deterministic fix — the bug was reproducible 100% of the time for any location where the convergence lands below threshold (e.g. equator at equinox). Fixes: sunrise/sunset showing identical times (~30% of location/date combinations)
1 parent 7cdc295 commit 9971f4c

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,12 @@ object CelestialComputer {
424424
}
425425
if (sunrise == 0.0) sunrise = daynum
426426

427-
// Phase 4: fast-forward through the day until sun drops back below threshold
428-
daynum = sunrise
427+
// Phase 4: fast-forward through the day until sun drops back below threshold.
428+
// Start from just after sunrise (small offset) so the sun is clearly above
429+
// the threshold. This prevents a bug where Phase 3 converges to a point
430+
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
431+
// to the same time as sunrise, producing identical sunrise/sunset times.
432+
daynum = sunrise + 0.001
429433
sunPos = getSunPosition(observer, daynumToMillis(daynum))
430434
guard = 0
431435
while (sunPos.elevation > -threshold && guard++ < 500) {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Look4Sat. Amateur radio satellite tracker and pass predictor.
3+
* Copyright (C) 2019-2026 Arty Bishop and contributors.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.rtbishop.look4sat.core.domain.predict
19+
20+
import org.junit.Assert.assertEquals
21+
import org.junit.Assert.assertTrue
22+
import org.junit.Test
23+
import java.time.Instant
24+
import kotlin.math.abs
25+
26+
class CelestialComputerTest {
27+
28+
private data class RiseSetCase(
29+
val name: String,
30+
val observer: GeoPos,
31+
val startIso: String
32+
)
33+
34+
@Test
35+
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
36+
val cases = listOf(
37+
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
38+
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
39+
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
40+
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
41+
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
42+
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
43+
)
44+
45+
cases.forEach { testCase ->
46+
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
47+
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
48+
49+
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
50+
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
51+
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
52+
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
53+
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
54+
55+
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
56+
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
57+
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
58+
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
59+
}
60+
}
61+
62+
@Test
63+
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
64+
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
65+
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
66+
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
67+
68+
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
69+
}
70+
}
71+
72+
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
73+
74+
private companion object {
75+
private const val SUNRISE_SET_THRESHOLD = -0.8333
76+
private const val HOUR_MILLIS = 60L * 60L * 1000L
77+
private const val DAY_MILLIS = 24L * HOUR_MILLIS
78+
}
79+
}

0 commit comments

Comments
 (0)