diff --git a/libs/angular-motion-lib/src/main/kotlin/space/nstart/pcp/angularmotion/SurveyContourCalculator.kt b/libs/angular-motion-lib/src/main/kotlin/space/nstart/pcp/angularmotion/SurveyContourCalculator.kt index 479aea6..b3acd1e 100644 --- a/libs/angular-motion-lib/src/main/kotlin/space/nstart/pcp/angularmotion/SurveyContourCalculator.kt +++ b/libs/angular-motion-lib/src/main/kotlin/space/nstart/pcp/angularmotion/SurveyContourCalculator.kt @@ -23,7 +23,7 @@ class SurveyContourCalculator( fun calculate(result: AngularMotionResult, y: Double,z: Double,focus: Double): String { - val sourcePoints = result.points + val sourcePoints = decimateContourSourcePoints(result.points) require(sourcePoints.size >= MIN_CONTOUR_POINTS) { "Для построения контура съемки требуется не менее $MIN_CONTOUR_POINTS точек ПУУД" } @@ -62,6 +62,43 @@ class SurveyContourCalculator( return toWkt(shell) } + + private fun decimateContourSourcePoints(points: List): List { + if (points.size <= MIN_CONTOUR_POINTS) { + return points + } + + val startTime = points.first().t + val stopTime = points.last().t + val duration = stopTime - startTime + if (duration <= 0.0) { + return listOf(points.first(), points.last()).distinct() + } + + val step = maxOf( + DEFAULT_CONTOUR_SOURCE_STEP_SEC, + duration / (MAX_CONTOUR_BOUNDARY_POINTS - 1), + ) + + val selected = ArrayList(MAX_CONTOUR_BOUNDARY_POINTS) + selected += points.first() + var nextTime = startTime + step + + for (index in 1 until points.lastIndex) { + val point = points[index] + if (point.t + TIME_EPS >= nextTime) { + selected += point + nextTime += step + } + } + + if (selected.last() !== points.last()) { + selected += points.last() + } + + return selected + } + private fun toWkt(points: List): String { val coordinates = points.joinToString(", ") { point -> String.format(Locale.US, "%.8f %.8f", point.longitudeDeg, point.latitudeDeg) @@ -85,5 +122,8 @@ class SurveyContourCalculator( private companion object { const val MIN_CONTOUR_POINTS = 2 + const val DEFAULT_CONTOUR_SOURCE_STEP_SEC = 2.0 + const val MAX_CONTOUR_BOUNDARY_POINTS = 10 + const val TIME_EPS = 1.0e-9 } } diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt index 3c5fdaa..8b74501 100644 --- a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt @@ -10,15 +10,21 @@ import org.nstart.dep265.tletools.zeptomoby.orbit.Satellite import java.util.Calendar import java.util.GregorianCalendar import java.util.TimeZone +import kotlin.math.floor +import kotlin.math.min class TLEStepper(str1: String, str2: String, earthType: EarthType) : AbstractStepper { val astro = AstronomerJ2000(earthType) val tleParser: TLE = TLE("", str1, str2) val satellite: Satellite = Satellite(tleParser) val baseEpoch: Double + private val nextAscendingNodeAfterEpoch: Double + private val ascendingNodeCache = mutableMapOf() init { baseEpoch = extractUTCMillis(satellite) / 1000.0 + 10800 + nextAscendingNodeAfterEpoch = findNextAscendingNodeAfterEpoch() + ascendingNodeCache[0] = nextAscendingNodeAfterEpoch } override fun clear() { @@ -68,7 +74,7 @@ class TLEStepper(str1: String, str2: String, earthType: EarthType) : AbstractSte val ask = OrbitalPoint( t, - satellite.orbit.orbitNum + (dt / satellite.orbit.period).toInt(), + calculateRevolution(t), Vector3D( pos.position.x * 1000.0, pos.position.y * 1000.0, @@ -87,6 +93,86 @@ class TLEStepper(str1: String, str2: String, earthType: EarthType) : AbstractSte } } + private fun calculateRevolution(t: Double): Int { + var nodeIndex = floor((t - nextAscendingNodeAfterEpoch) / satellite.orbit.period).toInt() + + while (t < ascendingNode(nodeIndex)) { + --nodeIndex + } + while (t >= ascendingNode(nodeIndex + 1)) { + ++nodeIndex + } + + return satellite.orbit.orbitNum + nodeIndex + 1 + } + + private fun ascendingNode(index: Int): Double = + ascendingNodeCache.getOrPut(index) { + val estimatedNode = nextAscendingNodeAfterEpoch + index * satellite.orbit.period + findAscendingNodeNear(estimatedNode) + } + + private fun findNextAscendingNodeAfterEpoch(): Double { + val scanStep = min(60.0, satellite.orbit.period / 64.0) + var left = baseEpoch + var leftZ = eciZ(left) + var right = left + scanStep + val scanLimit = baseEpoch + satellite.orbit.period * 2.0 + + while (right <= scanLimit) { + val rightZ = eciZ(right) + if (leftZ < 0.0 && rightZ >= 0.0) { + return refineAscendingNode(left, right) + } + left = right + leftZ = rightZ + right += scanStep + } + + return baseEpoch + satellite.orbit.period + } + + private fun findAscendingNodeNear(estimatedNode: Double): Double { + val halfPeriod = satellite.orbit.period / 2.0 + val scanStep = min(60.0, satellite.orbit.period / 64.0) + var left = estimatedNode - halfPeriod + var leftZ = eciZ(left) + var right = left + scanStep + val scanLimit = estimatedNode + halfPeriod + + while (right <= scanLimit) { + val rightZ = eciZ(right) + if (leftZ < 0.0 && rightZ >= 0.0) { + return refineAscendingNode(left, right) + } + left = right + leftZ = rightZ + right += scanStep + } + + return estimatedNode + } + + private fun refineAscendingNode( + intervalStart: Double, + intervalStop: Double, + ): Double { + var left = intervalStart + var right = intervalStop + repeat(60) { + val middle = (left + right) / 2.0 + if (eciZ(middle) < 0.0) { + left = middle + } else { + right = middle + } + } + return (left + right) / 2.0 + } + + private fun eciZ(t: Double): Double = + satellite.positionEci((t - baseEpoch) / 60.0).position.z + override fun calculate( t: Double, p: OrbitalPoint, diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepperTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepperTest.kt new file mode 100644 index 0000000..e75aeff --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepperTest.kt @@ -0,0 +1,33 @@ +package ballistics.orbitalPoints.timeStepper + +import ballistics.types.EarthType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneOffset + +internal class TLEStepperTest { + @Test + fun `calculate keeps TLE revolution at epoch`() { + val stepper = TLEStepper(issTleFirst, issTleSecond, EarthType.PZ90d02) + + val point = stepper.calculate(stepper.baseEpoch) + + assertEquals(40945, point.vit) + } + + @Test + fun `calculate increments revolution at ascending node instead of elapsed period from epoch`() { + val stepper = TLEStepper(issTleFirst, issTleSecond, EarthType.PZ90d02) + + val firstPass = stepper.calculate(LocalDateTime.of(2026, 6, 16, 14, 19, 50).toEpochSecond(ZoneOffset.UTC).toDouble()) + val secondPass = stepper.calculate(LocalDateTime.of(2026, 6, 16, 15, 56, 14).toEpochSecond(ZoneOffset.UTC).toDouble()) + + assertEquals(firstPass.vit + 1, secondPass.vit) + } + + private companion object { + const val issTleFirst = "1 25994U 99068A 26167.13049375 .00000315 00000-0 73126-4 0 9997" + const val issTleSecond = "2 25994 97.9457 216.7186 0001501 193.3628 244.8059 14.61102329409455" + } +} diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt index a40785a..67a1c23 100644 --- a/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt @@ -10,6 +10,7 @@ import ballistics.types.OrbitalPoint import ballistics.types.PPI import ballistics.types.TLE import ballistics.utils.math.Vector3D +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.time.LocalDateTime @@ -17,6 +18,49 @@ import java.time.ZoneOffset import kotlin.math.PI internal class ZRVStepperCalculatorTest { + @Test + fun `calculate keeps different revolutions for consecutive TLE visibility zones`() { + val ballistics = Ballistics() + val tle = TLE( + "1 25994U 99068A 26167.13049375 .00000315 00000-0 73126-4 0 9997", + "2 25994 97.9457 216.7186 0001501 193.3628 244.8059 14.61102329409455", + ) + val timeStart = LocalDateTime.of(2026, 6, 16, 10, 41, 50, 189_000_000).toEpochSecond(ZoneOffset.UTC).toDouble() + val timeStop = LocalDateTime.of(2026, 6, 18, 0, 41, 50, 189_000_000).toEpochSecond(ZoneOffset.UTC).toDouble() + + assertEquals(BallisticsError.OK, ballistics.calculateOrbPoints(tle, timeStart, timeStop)) + assertEquals( + BallisticsError.OK, + ballistics.calculateZRV( + listOf( + PPI( + 1, + 0, + 69.038863 * PI / 180.0, + 32.861553 * PI / 180.0, + 124.67, + 5 * PI / 180.0, + 90 * PI / 180.0, + ), + ), + timeStart, + timeStop, + ), + ) + + val zones = ballistics.zrv.toList() + val firstZone = zones.first { + it.zoneIn.t >= LocalDateTime.of(2026, 6, 16, 14, 0).toEpochSecond(ZoneOffset.UTC) && + it.zoneIn.t < LocalDateTime.of(2026, 6, 16, 15, 0).toEpochSecond(ZoneOffset.UTC) + } + val secondZone = zones.first { + it.zoneIn.t >= LocalDateTime.of(2026, 6, 16, 15, 0).toEpochSecond(ZoneOffset.UTC) && + it.zoneIn.t < LocalDateTime.of(2026, 6, 16, 17, 0).toEpochSecond(ZoneOffset.UTC) + } + + assertEquals(firstZone.vit + 1, secondZone.vit) + } + @Test fun calculate() { val r = Ballistics()