Files
astro/internal/occultationgeo/validation.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

156 lines
5.8 KiB
Go

package occultationgeo
import (
"fmt"
"math"
"time"
"b612.me/astro/basic"
)
// ValidateRiseSetCurves 检查 SVG 和 GeoJSON 渲染器消费的公开地平曲线数据。
// ValidateRiseSetCurves checks the public horizon-curve data consumed by SVG and GeoJSON renderers.
func ValidateRiseSetCurves(curves []basic.OccultationRiseSetCurve, start, end time.Time) error {
seen := make(map[[2]string]bool, len(curves))
for curveIndex, curve := range curves {
if !validRiseSetPhase(curve.Phase) {
return fmt.Errorf("curve[%d] has unsupported phase %q", curveIndex, curve.Phase)
}
if !validRiseSetDirection(curve.Direction) {
return fmt.Errorf("curve[%d] has unsupported direction %q", curveIndex, curve.Direction)
}
key := [2]string{string(curve.Phase), string(curve.Direction)}
if seen[key] {
return fmt.Errorf("curve[%d] duplicates phase %q and direction %q", curveIndex, curve.Phase, curve.Direction)
}
seen[key] = true
if len(curve.Segments) == 0 {
return fmt.Errorf("curve[%d] must contain at least one segment", curveIndex)
}
for segmentIndex, segment := range curve.Segments {
if len(segment) < 2 {
return fmt.Errorf("curve[%d].segment[%d] must contain at least two points", curveIndex, segmentIndex)
}
for pointIndex, point := range segment {
if err := validatePathPoint(point, start, end); err != nil {
return fmt.Errorf("curve[%d].segment[%d].point[%d]: %w", curveIndex, segmentIndex, pointIndex, err)
}
if pointIndex > 0 && !point.Time.After(segment[pointIndex-1].Time) {
return fmt.Errorf("curve[%d].segment[%d] times must be strictly increasing", curveIndex, segmentIndex)
}
if pointIndex > 0 && riseSetCurveBranchChanged(segment[pointIndex-1], point) {
return fmt.Errorf("curve[%d].segment[%d] contains a discontinuous branch jump between points %d and %d", curveIndex, segmentIndex, pointIndex-1, pointIndex)
}
}
}
}
return nil
}
func riseSetCurveBranchChanged(first, second basic.OccultationPathPoint) bool {
distance := DistanceKM(first, second)
if !finite(distance) {
return true
}
if distance <= 500 {
return false
}
duration := math.Abs(second.Time.Sub(first.Time).Seconds())
return duration <= 0 || duration < 1 && distance/duration > 10
}
func validRiseSetPhase(phase basic.RiseSetPhase) bool {
switch phase {
case basic.RiseSetPhaseStart, basic.RiseSetPhaseGreatest, basic.RiseSetPhaseEnd:
return true
default:
return false
}
}
func validRiseSetDirection(direction basic.RiseSetDirection) bool {
switch direction {
case basic.RiseSetDirectionRise, basic.RiseSetDirectionSet:
return true
default:
return false
}
}
// ValidateFootprints 检查 SVG 和 GeoJSON 渲染器消费的瞬时月掩区域。
// ValidateFootprints checks instantaneous occultation regions consumed by SVG and GeoJSON renderers.
func ValidateFootprints(footprints []basic.OccultationFootprint, start, end time.Time) error {
previous := time.Time{}
for footprintIndex, footprint := range footprints {
if footprint.Time.IsZero() {
return fmt.Errorf("footprint[%d] time is required", footprintIndex)
}
if footprint.Time.Before(start) || footprint.Time.After(end) {
return fmt.Errorf("footprint[%d] time must be inside its contact interval", footprintIndex)
}
if !previous.IsZero() && !footprint.Time.After(previous) {
return fmt.Errorf("footprint times must be strictly increasing")
}
if len(footprint.Polygons) == 0 {
return fmt.Errorf("footprint[%d] must contain a polygon", footprintIndex)
}
for polygonIndex, polygon := range footprint.Polygons {
if len(polygon) < 3 {
return fmt.Errorf("footprint[%d].polygon[%d] must contain at least three points", footprintIndex, polygonIndex)
}
for pointIndex, point := range polygon {
if err := validatePathPoint(point, start, end); err != nil {
return fmt.Errorf("footprint[%d].polygon[%d].point[%d]: %w", footprintIndex, polygonIndex, pointIndex, err)
}
if !point.Time.Equal(footprint.Time) {
return fmt.Errorf("footprint[%d].polygon[%d].point[%d] time must match its footprint", footprintIndex, polygonIndex, pointIndex)
}
}
}
if footprint.Closed && len(footprint.Boundaries) != 1 {
return fmt.Errorf("footprint[%d] closed boundary must contain exactly one segment", footprintIndex)
}
for boundaryIndex, boundary := range footprint.Boundaries {
if len(boundary) < 2 {
return fmt.Errorf("footprint[%d].boundary[%d] must contain at least two points", footprintIndex, boundaryIndex)
}
for pointIndex, point := range boundary {
if err := validatePathPoint(point, start, end); err != nil {
return fmt.Errorf("footprint[%d].boundary[%d].point[%d]: %w", footprintIndex, boundaryIndex, pointIndex, err)
}
if !point.Time.Equal(footprint.Time) {
return fmt.Errorf("footprint[%d].boundary[%d].point[%d] time must match its footprint", footprintIndex, boundaryIndex, pointIndex)
}
}
}
previous = footprint.Time
}
return nil
}
func validatePathPoint(point basic.OccultationPathPoint, start, end time.Time) error {
if point.Time.IsZero() {
return fmt.Errorf("time is required")
}
if point.Time.Before(start) || point.Time.After(end) {
return fmt.Errorf("time must be inside the occultation interval")
}
if !finite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 {
return fmt.Errorf("longitude must be finite and within [-180, 180]")
}
if !finite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("latitude must be finite and within [-90, 90]")
}
if !finite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
return fmt.Errorf("Moon altitude must be finite and within [-90, 90]")
}
if !finite(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("width must be finite and non-negative")
}
return nil
}
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}