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 new file mode 100644 index 0000000..d45a0ad --- /dev/null +++ b/libs/angular-motion-lib/src/main/kotlin/space/nstart/pcp/angularmotion/SurveyContourCalculator.kt @@ -0,0 +1,196 @@ +package space.nstart.pcp.angularmotion + +import ballistics.mpl.OrientOnPointCalculator +import ballistics.types.BLHPoint +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.types.TangageType +import ballistics.types.WorkCSType +import ballistics.utils.earth.getEarth +import java.util.Locale +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Строит WKT-контур полосы съемки по результату ПУУД. + * + * [captureAngleDeg] трактуется как полуширина полосы: правая/левая граница соответствуют + * ориентациям на точки с креном `centerKren + capture` и `centerKren - capture`. + */ +class SurveyContourCalculator( + earthType: EarthType = EarthType.PZ90d02, + wcs: WorkCSType = WorkCSType.WCSOrbit, + tangageType: TangageType = TangageType.TTProactive, +) { + private val earth = getEarth(earthType) + private val orientCalculator = OrientOnPointCalculator(earthType, wcs, tangageType) + + fun calculate(result: AngularMotionResult, captureAngleDeg: Double): String { + require(captureAngleDeg > 0.0 && captureAngleDeg.isFinite()) { + "Угол захвата аппаратуры должен быть положительным" + } + + val sourcePoints = result.points.filter { it.groundPoint != null } + require(sourcePoints.size >= MIN_CONTOUR_POINTS) { + "Для построения контура съемки требуется не менее $MIN_CONTOUR_POINTS точек ПУУД" + } + + val captureAngleRad = captureAngleDeg.toRadians() + val right = ArrayList(sourcePoints.size) + val left = ArrayList(sourcePoints.size) + + sourcePoints.forEach { point -> + val centerKren = orientationKren(point.orbitalPoint, point.groundPoint!!) + right += boundaryPoint(point.orbitalPoint, point.groundPoint!!, centerKren + captureAngleRad) + left += boundaryPoint(point.orbitalPoint, point.groundPoint!!, centerKren - captureAngleRad) + } + + val shell = ArrayList(right.size + left.size + 1) + shell += right + for (index in left.indices.reversed()) { + shell += left[index] + } + shell += shell.first() + return toWkt(shell) + } + + private fun boundaryPoint(orbitalPoint: OrbitalPoint, center: BLHPoint, targetKren: Double): SurveyContourPoint { + val centerKren = orientationKren(orbitalPoint, center) + val targetDelta = angleDelta(targetKren, centerKren) + if (abs(targetDelta) < EPS) { + return center.toContourPoint() + } + + val direction = krenGradientDirection(orbitalPoint, center, centerKren, targetDelta) + var high = initialSearchDistanceMeters(orbitalPoint, center, centerKren, targetDelta, direction) + var highDelta = projectedKrenDelta(orbitalPoint, center, centerKren, direction, high) + + while (sameDirection(targetDelta, highDelta) && abs(highDelta) < abs(targetDelta) && high < MAX_SEARCH_DISTANCE_M) { + high = (high * 2.0).coerceAtMost(MAX_SEARCH_DISTANCE_M) + highDelta = projectedKrenDelta(orbitalPoint, center, centerKren, direction, high) + } + + var low = 0.0 + repeat(BINARY_SEARCH_ITERATIONS) { + val mid = (low + high) / 2.0 + val midDelta = projectedKrenDelta(orbitalPoint, center, centerKren, direction, mid) + if (sameDirection(targetDelta, midDelta) && abs(midDelta) < abs(targetDelta)) { + low = mid + } else { + high = mid + } + } + + return shift(center, direction, high).toContourPoint() + } + + private fun krenGradientDirection( + orbitalPoint: OrbitalPoint, + center: BLHPoint, + centerKren: Double, + targetDelta: Double, + ): GroundDirection { + val eastKren = orientationKren(orbitalPoint, shift(center, GroundDirection(1.0, 0.0), GRADIENT_STEP_M)) + val northKren = orientationKren(orbitalPoint, shift(center, GroundDirection(0.0, 1.0), GRADIENT_STEP_M)) + val eastDelta = angleDelta(eastKren, centerKren) + val northDelta = angleDelta(northKren, centerKren) + val norm = sqrt(eastDelta * eastDelta + northDelta * northDelta) + val sign = if (targetDelta >= 0.0) 1.0 else -1.0 + + return if (norm < EPS) { + GroundDirection(sign, 0.0) + } else { + GroundDirection(sign * eastDelta / norm, sign * northDelta / norm) + } + } + + private fun initialSearchDistanceMeters( + orbitalPoint: OrbitalPoint, + center: BLHPoint, + centerKren: Double, + targetDelta: Double, + direction: GroundDirection, + ): Double { + val deltaAtStep = abs(projectedKrenDelta(orbitalPoint, center, centerKren, direction, GRADIENT_STEP_M)) + if (deltaAtStep < EPS) return DEFAULT_SEARCH_DISTANCE_M + return max(GRADIENT_STEP_M, GRADIENT_STEP_M * abs(targetDelta) / deltaAtStep * 1.25) + .coerceAtMost(MAX_SEARCH_DISTANCE_M) + } + + private fun projectedKrenDelta( + orbitalPoint: OrbitalPoint, + center: BLHPoint, + centerKren: Double, + direction: GroundDirection, + distanceMeters: Double, + ): Double = angleDelta(orientationKren(orbitalPoint, shift(center, direction, distanceMeters)), centerKren) + + private fun orientationKren(orbitalPoint: OrbitalPoint, point: BLHPoint): Double = + orientCalculator.calculateOrientOnPoint( + orbitalPoint, + earth.blh2xyz(point.lat, point.long, point.h), + ).kren + + private fun shift(center: BLHPoint, direction: GroundDirection, distanceMeters: Double): BLHPoint { + val latitude = center.lat + direction.north * distanceMeters / earth.middleRadius + val parallelRadius = (earth.middleRadius * cos(center.lat)).coerceAwayFromZero() + val longitude = normalizeLongitude(center.long + direction.east * distanceMeters / parallelRadius) + return BLHPoint(clamp(latitude, -MAX_LATITUDE_RAD, MAX_LATITUDE_RAD), longitude, center.h) + } + + private fun sameDirection(expected: Double, actual: Double): Boolean = + actual == 0.0 || expected == 0.0 || expected * actual > 0.0 + + private fun angleDelta(value: Double, reference: Double): Double = normalizeAngle(value - reference) + + private fun clamp(value: Double, min: Double, max: Double): Double = + when { + value < min -> min + value > max -> max + else -> value + } + + private fun toWkt(points: List): String { + val coordinates = points.joinToString(", ") { point -> + String.format(Locale.US, "%.8f %.8f", point.longitudeDeg, point.latitudeDeg) + } + return "POLYGON (($coordinates))" + } + + private fun BLHPoint.toContourPoint(): SurveyContourPoint = + SurveyContourPoint(latitudeDeg = lat.toDegrees(), longitudeDeg = long.toDegrees()) + + private fun Double.toRadians(): Double = this * PI / 180.0 + + private fun Double.toDegrees(): Double = this * 180.0 / PI + + private fun normalizeLongitude(value: Double): Double { + var longitude = value + while (longitude > PI) longitude -= 2.0 * PI + while (longitude < -PI) longitude += 2.0 * PI + return longitude + } + + private data class GroundDirection( + val east: Double, + val north: Double, + ) + + private data class SurveyContourPoint( + val latitudeDeg: Double, + val longitudeDeg: Double, + ) + + private companion object { + const val MIN_CONTOUR_POINTS = 2 + const val GRADIENT_STEP_M = 1_000.0 + const val DEFAULT_SEARCH_DISTANCE_M = 50_000.0 + const val MAX_SEARCH_DISTANCE_M = 2_000_000.0 + const val BINARY_SEARCH_ITERATIONS = 48 + const val MAX_LATITUDE_RAD = PI / 2.0 - 1.0e-8 + } +} diff --git a/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt b/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt index 22c88eb..2461e92 100644 --- a/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt +++ b/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt @@ -61,6 +61,29 @@ class AngularMotionCalculatorSmokeTest { assertTrue(result.points.isNotEmpty()) } + @Test + fun `survey contour calculator returns closed wkt polygon`() { + val result = ConstOrientPUUD(CircularStepper()).calculate( + SurveyId( + t = 1000.0, + b = 0.1, + l = 0.2, + h = 0.0, + duration = 1.0, + ) + ) + + val wkt = SurveyContourCalculator().calculate(result, captureAngleDeg = 1.5) + val coordinates = wkt + .removePrefix("POLYGON ((") + .removeSuffix("))") + .split(", ") + + assertTrue(wkt.startsWith("POLYGON ((")) + assertTrue(coordinates.size >= result.points.size * 2 + 1) + assertEquals(coordinates.first(), coordinates.last()) + } + private class CircularStepper : AbstractStepper { private val radius = 7_000_000.0 private val omega = 0.001 diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTO.kt index 7b2af05..fa56960 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTO.kt @@ -11,6 +11,7 @@ data class AngularMotionCalculationRequestDTO( val longitudeDeg: Double, val heightM: Double = 0.0, val durationSec: Double, + val captureAngleDeg: Double = 1.5, val leadAngleDeg: Double = 0.0, val azimuthDeg: Double = 0.0, val sdi: Double? = null, diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTOJacksonTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTOJacksonTest.kt index 86b3df4..db7a14c 100644 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTOJacksonTest.kt +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AngularMotionCalculationRequestDTOJacksonTest.kt @@ -24,6 +24,7 @@ class AngularMotionCalculationRequestDTOJacksonTest { "longitudeDeg": 37.61, "heightM": 0, "durationSec": 60, + "captureAngleDeg": 1.5, "leadAngleDeg": 0, "azimuthDeg": 0, "pointInCenter": false, @@ -39,6 +40,7 @@ class AngularMotionCalculationRequestDTOJacksonTest { assertEquals(AngularMotionModeDTO.CONST_ORIENT, request.mode) assertEquals(55.75, request.latitudeDeg) assertEquals(37.61, request.longitudeDeg) + assertEquals(1.5, request.captureAngleDeg) assertFalse(request.pointInCenter) } } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt index f364f9c..7a615c8 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt @@ -28,6 +28,7 @@ data class AngularMotionCalculationResultDTO( val satelliteId: Long, val approximateTime: LocalDateTime, val startTime: LocalDateTime, + val contourWkt: String?, val points: List, ) diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt index 9521362..9097e8e 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service import space.nstart.pcp.angularmotion.AngularMotionCalculatorFactory import space.nstart.pcp.angularmotion.AngularMotionConfig import space.nstart.pcp.angularmotion.AngularMotionMode +import space.nstart.pcp.angularmotion.SurveyContourCalculator import space.nstart.pcp.angularmotion.SurveyId import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException import space.nstart.pcp.pcp_request_service.service.SatelliteCatalogClient @@ -91,12 +92,14 @@ class AngularMotionService( } catch (error: RuntimeException) { throw CustomValidationException(error.message ?: "Ошибка расчета ПУУД") } + val contourWkt = SurveyContourCalculator(EarthType.PZ90d02).calculate(result, request.captureAngleDeg) return AngularMotionCalculationResultDTO( mode = result.mode, satelliteId = request.satelliteId, approximateTime = request.approximateTime, startTime = toDateTime(result.startTime), + contourWkt = contourWkt, points = result.points.map { point -> AngularMotionPointDTO( time = toDateTime(point.t), @@ -122,6 +125,9 @@ class AngularMotionService( if (request.durationSec <= 0.0) { throw CustomValidationException("durationSec must be positive") } + if (request.captureAngleDeg <= 0.0 || !request.captureAngleDeg.isFinite()) { + throw CustomValidationException("captureAngleDeg must be positive") + } if (request.latitudeDeg !in -90.0..90.0) { throw CustomValidationException("latitudeDeg must be in [-90; 90]") } diff --git a/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js b/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js index cbcd08b..6a72dfc 100644 --- a/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js @@ -284,6 +284,54 @@ }); } + function polygonCoordinatesFromWkt(wkt) { + const textValue = text(wkt).trim(); + const match = textValue.match(/^POLYGON\s*\(\((.*)\)\)$/i); + if (!match) return []; + + return match[1].split(',') + .map(item => item.trim().split(/\s+/).map(Number)) + .filter(pair => pair.length >= 2 && Number.isFinite(pair[0]) && Number.isFinite(pair[1])) + .map(pair => ({ lon: pair[0], lat: pair[1] })); + } + + function surveyContourBounds() { + const coordinates = polygonCoordinatesFromWkt(state.result?.contourWkt); + return { + lats: coordinates.map(point => point.lat), + lons: coordinates.map(point => point.lon), + }; + } + + function addSurveyContour() { + if (!state.mapDataSource || !state.result?.contourWkt) return; + const coordinates = polygonCoordinatesFromWkt(state.result.contourWkt); + if (coordinates.length < 4) return; + + const degrees = []; + coordinates.forEach(point => { + degrees.push(point.lon, point.lat); + }); + const positions = Cesium.Cartesian3.fromDegreesArray(degrees); + state.mapDataSource.entities.add({ + name: 'Контур съемки', + polygon: { + hierarchy: new Cesium.PolygonHierarchy(positions.slice(0, -1)), + material: Cesium.Color.ORANGE.withAlpha(0.22), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + }); + state.mapDataSource.entities.add({ + name: 'Граница контура съемки', + polyline: { + positions, + width: 2, + material: Cesium.Color.ORANGE, + clampToGround: true, + }, + }); + } + function addTarget() { if (!state.mapDataSource) return; const lat = Number(el('angular-motion-lat').value); @@ -327,6 +375,9 @@ if (Number.isFinite(value)) lons.push(value); }); }); + const contourBounds = surveyContourBounds(); + lats.push(...contourBounds.lats); + lons.push(...contourBounds.lons); if (lats.length === 0 || lons.length === 0) return; const minLat = Math.max(-89.0, Math.min(...lats) - 1.0); const maxLat = Math.min(89.0, Math.max(...lats) + 1.0); @@ -346,6 +397,7 @@ addPolyline(points, 'leftLatitudeDeg', 'leftLongitudeDeg', Cesium.Color.CORNFLOWERBLUE.withAlpha(0.85), 2, 'Левая граница полосы'); addPolyline(points, 'rightLatitudeDeg', 'rightLongitudeDeg', Cesium.Color.CORNFLOWERBLUE.withAlpha(0.85), 2, 'Правая граница полосы'); addPolyline(points, 'latitudeDeg', 'longitudeDeg', Cesium.Color.YELLOW, 3, 'Трасса полета'); + addSurveyContour(); addTarget(); if (fit) fitMapToData(); state.viewer.scene.requestRender(); @@ -377,6 +429,7 @@ longitudeDeg: Number(el('angular-motion-lon').value), heightM: Number(el('angular-motion-height').value || 0), durationSec: Number(el('angular-motion-duration').value), + captureAngleDeg: Number(el('angular-motion-capture-angle').value || 1.5), leadAngleDeg: Number(el('angular-motion-lead-angle').value || 0), azimuthDeg: Number(el('angular-motion-azimuth').value || 0), pointInCenter: false, @@ -394,7 +447,7 @@ state.result = result; const rows = Array.isArray(result?.points) ? result.points : []; el('angular-motion-result-meta').textContent = rows.length > 0 - ? `Старт режима: ${formatDateTime(result.startTime)}. Точек: ${rows.length}.` + ? `Старт режима: ${formatDateTime(result.startTime)}. Точек: ${rows.length}. Контур: ${result?.contourWkt ? 'построен' : 'нет'}.` : 'Расчет не вернул точек.'; el('angular-motion-export-csv').disabled = rows.length === 0; const body = el('angular-motion-result-body'); @@ -420,6 +473,7 @@ ${escapeHtml(formatNumber(point.sdi, 4))} `).join(''); + drawMap(true); } async function calculate(event) { diff --git a/services/pcp-ui-service/src/main/resources/templates/angular-motion.html b/services/pcp-ui-service/src/main/resources/templates/angular-motion.html index 974b243..f325299 100644 --- a/services/pcp-ui-service/src/main/resources/templates/angular-motion.html +++ b/services/pcp-ui-service/src/main/resources/templates/angular-motion.html @@ -78,10 +78,17 @@
+
+ + +
+
+ +