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 + } +} diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt index db31dfc1..33e17381 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt @@ -21,6 +21,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF +import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.overlay.Overlay import kotlin.math.cos @@ -39,9 +40,11 @@ import kotlin.math.sin * exceeds 90°, i.e. the dot product of the two unit vectors is negative: * dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0 * - * Performance: we sample one column per [stepPx] pixels (default 4) and draw + * Performance: we sample one column per stepPx pixels (default 4) and draw * filled vertical rectangles. On a 1080-wide screen this means ~270 trig - * evaluations per row, which is imperceptible. + * evaluations per row, which is imperceptible. draw() is called on every + * frame, so it must stay allocation-free — all fromPixels() calls reuse a + * single GeoPoint instance. */ class MapNightOverlay : Overlay() { @@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() { private val rect = RectF() + /** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */ + private val reusableGeoPoint = GeoPoint(0.0, 0.0) + override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { if (shadow) return @@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() { val h = mapView.height val stepPx = 4 // sample every N pixels — balance quality vs CPU + // The map is never rotated, so latitude depends only on y and longitude only on x. + // Resolve the top/bottom latitudes once instead of once per column. + val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude) + val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude) + val sinLatTop = sin(latTopRad) + val cosLatTop = cos(latTopRad) + val sinLatBot = sin(latBotRad) + val cosLatBot = cos(latBotRad) + // We scan column by column. For each column we determine the longitude, // then find the latitude range that is in night and shade it. // Since longitude is constant along a vertical strip and the day/night @@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() { var x = 0 while (x < w) { - // Get the geographic coordinate at the top and bottom of this column. - val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue } - val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue } - + val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue } val lonRad = Math.toRadians(geoTop.longitude) val cosLonDiff = cos(lonRad - sunLonRad) - // Top pixel geographic lat - val latTopRad = Math.toRadians(geoTop.latitude) - // Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat) - val latBotRad = Math.toRadians(geoBot.latitude) - // dot(sunVec, pointVec) < 0 → night // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff - val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff - val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff + val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff + val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff when { dotTop < 0 && dotBot < 0 -> { @@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() { var hi = yBot while (hi - lo > 1) { val mid = (lo + hi) / 2 - val geo = proj.fromPixels(x, mid) ?: return mid + val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid val latRad = Math.toRadians(geo.latitude) val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff if (dot < 0) hi = mid else lo = mid diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 2e9f8224..b580c732 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -32,8 +32,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -166,6 +169,7 @@ private fun RadarScreen( Column( modifier = Modifier .layoutPadding() + .padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) .keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp) ) {