From 9971f4c4ce43dad09086f476cf52ae9f41abb8d6 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 14 Aug 2026 20:52:12 +0800 Subject: [PATCH] fix: prevent sunrise/sunset times being identical when Phase 3 convergence ends below threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../core/domain/predict/CelestialComputer.kt | 8 +- .../domain/predict/CelestialComputerTest.kt | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt index 6a3cdbd8..45b4db01 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt @@ -424,8 +424,12 @@ object CelestialComputer { } if (sunrise == 0.0) sunrise = daynum - // Phase 4: fast-forward through the day until sun drops back below threshold - daynum = sunrise + // Phase 4: fast-forward through the day until sun drops back below threshold. + // Start from just after sunrise (small offset) so the sun is clearly above + // the threshold. This prevents a bug where Phase 3 converges to a point + // slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge + // to the same time as sunrise, producing identical sunrise/sunset times. + daynum = sunrise + 0.001 sunPos = getSunPosition(observer, daynumToMillis(daynum)) guard = 0 while (sunPos.elevation > -threshold && guard++ < 500) { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt new file mode 100644 index 00000000..6685e85c --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt @@ -0,0 +1,79 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.predict + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import kotlin.math.abs + +class CelestialComputerTest { + + private data class RiseSetCase( + val name: String, + val observer: GeoPos, + val startIso: String + ) + + @Test + fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() { + val cases = listOf( + RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"), + RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"), + RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"), + RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"), + RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"), + RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z") + ) + + cases.forEach { testCase -> + val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis()) + val daylightDuration = result.setTimeMillis - result.riseTimeMillis + + assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L) + assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L) + assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis) + assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS) + assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS) + + val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation + val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation + assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02) + assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02) + } + } + + @Test + fun `findSunRiseSet does not return the same instant for equinox regression cases`() { + listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso -> + val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis()) + val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis) + + assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS) + } + } + + private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli() + + private companion object { + private const val SUNRISE_SET_THRESHOLD = -0.8333 + private const val HOUR_MILLIS = 60L * 60L * 1000L + private const val DAY_MILLIS = 24L * HOUR_MILLIS + } +}