package basic import ( "math" "sort" "time" ) const ( planetOccultationDefaultStepDays = 0.25 planetOccultationCandidateLimitArcsec = 10 * 3600.0 planetOccultationLatitudeMarginArcsec = 3600.0 planetOccultationContactStepDays = 10.0 / 1440.0 planetOccultationContactSpanDays = 2.0 planetOccultationGrazingToleranceArcsec = 0.01 planetOccultationRootToleranceDays = occultationEventSelectionToleranceDays planetOccultationMaxContactSteps = 10000 ) type planetOccultationConfig struct { planet OccultationPlanet equatorialRadiusKM float64 apparentRaDecN func(float64, int) (float64, float64) earthDistanceN func(float64, int) float64 semidiameterN func(float64, int) float64 } type planetMoonPosition struct { moonRA, moonDec float64 planetRA, planetDec float64 valid bool } type planetOccultationState struct { position planetMoonPosition separationArcsec float64 moonSemidiameter float64 planetSemidiameter float64 externalContactMetric float64 internalContactMetric float64 valid bool } // FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。 // 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。 // FindPlanetOccultations searches one finite-disk planet at a fixed observing site. // Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters. The target position, parallax, and semidiameter are recomputed at every candidate, greatest, and contact evaluation. func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64, options OccultationSearchOptions) ([]PlanetOccultationInfo, error) { if err := validateOccultationTimeRange(start, end); err != nil { return nil, err } if err := planet.Validate(); err != nil { return nil, err } observer := Observer{Longitude: longitude, Latitude: latitude, Height: height} if err := observer.Validate(); err != nil { return nil, err } if err := options.Validate(); err != nil { return nil, err } config, _ := planetOccultationConfigFor(planet) startTT := occultationTimeToTT(start) endTT := occultationTimeToTT(end) resultLocation := start.Location() candidates := planetOccultationCandidateGreatestTimes( startTT, endTT, planetOccultationCoarseStepDays(options), config, &observer, options.SafetyMarginArcsec, ) results := make([]PlanetOccultationInfo, 0, len(candidates)) for _, greatestTT := range candidates { info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation) if !ok { continue } if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 { results = append(results, info) if options.MaxEvents > 0 && len(results) >= options.MaxEvents { break } } } sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) }) return results, nil } // FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点。 // 地心数据只用于搜索初值;返回位置与 FindPlanetOccultationPaths 一致,地平线可见性只报告、不参与点选择。距离查询端点 10 ms 内的掩甚时刻也会包含,与数值根精度一致。 // FindBestPlanetOccultations returns the global geometric greatest point at sea level for every finite-disk planetary occultation in the window. // Geocentric data only seeds the search. The returned location matches FindPlanetOccultationPaths; horizon visibility is reported but does not select the point. A greatest instant within 10 ms of either query endpoint is included, matching the numerical root precision. func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet, options OccultationSearchOptions) ([]PlanetOccultationInfo, error) { if err := validateOccultationTimeRange(start, end); err != nil { return nil, err } if err := planet.Validate(); err != nil { return nil, err } if err := options.Validate(); err != nil { return nil, err } config, _ := planetOccultationConfigFor(planet) startTT := occultationTimeToTT(start) endTT := occultationTimeToTT(end) selectionStartTT := startTT - occultationEventSelectionToleranceDays selectionEndTT := endTT + occultationEventSelectionToleranceDays candidateStartTT := startTT - occultationPathSearchSpanDays candidateEndTT := endTT + occultationPathSearchSpanDays resultLocation := start.Location() candidates := planetOccultationCandidateGreatestTimes( candidateStartTT, candidateEndTT, planetOccultationCoarseStepDays(options), config, nil, options.SafetyMarginArcsec, ) results := make([]PlanetOccultationInfo, 0, len(candidates)) for _, seedTT := range candidates { greatestTT, observer, _, observerOK := planetOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, config) if !observerOK { continue } info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation) if !ok { continue } if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 { results = append(results, info) if options.MaxEvents > 0 && len(results) >= options.MaxEvents { break } } } sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) }) return results, nil } func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConfig, bool) { config := planetOccultationConfig{planet: planet} switch planet { case OccultationMercury: config.equatorialRadiusKM = mercuryEquatorialRadiusKM config.apparentRaDecN = MercuryApparentRaDecN config.earthDistanceN = EarthMercuryAwayN config.semidiameterN = MercurySemidiameterN case OccultationVenus: config.equatorialRadiusKM = venusEquatorialRadiusKM config.apparentRaDecN = VenusApparentRaDecN config.earthDistanceN = EarthVenusAwayN config.semidiameterN = VenusSemidiameterN case OccultationMars: config.equatorialRadiusKM = marsEquatorialRadiusKM config.apparentRaDecN = MarsApparentRaDecN config.earthDistanceN = EarthMarsAwayN config.semidiameterN = MarsSemidiameterN case OccultationJupiter: config.equatorialRadiusKM = jupiterEquatorialRadiusKM config.apparentRaDecN = JupiterApparentRaDecN config.earthDistanceN = EarthJupiterAwayN config.semidiameterN = JupiterSemidiameterN case OccultationSaturn: config.equatorialRadiusKM = saturnEquatorialRadiusKM config.apparentRaDecN = SaturnApparentRaDecN config.earthDistanceN = EarthSaturnAwayN config.semidiameterN = SaturnSemidiameterN case OccultationUranus: config.equatorialRadiusKM = uranusEquatorialRadiusKM config.apparentRaDecN = UranusApparentRaDecN config.earthDistanceN = EarthUranusAwayN config.semidiameterN = UranusSemidiameterN case OccultationNeptune: config.equatorialRadiusKM = neptuneEquatorialRadiusKM config.apparentRaDecN = NeptuneApparentRaDecN config.earthDistanceN = EarthNeptuneAwayN config.semidiameterN = NeptuneSemidiameterN default: return planetOccultationConfig{}, false } return config, true } func planetOccultationCandidateGreatestTimes( startTT, endTT, step float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64, ) []float64 { if endTT <= startTT { return nil } duration := endTT - startTT if step > duration/4 { step = math.Max(duration/4, 0.25/86400.0) } step = math.Max(step, 0.25/86400.0) scanStart := startTT - step scanEnd := endTT + step leftTT := scanStart centerTT := math.Min(leftTT+step, scanEnd) leftValue := planetOccultationExternalContactMetric(leftTT, config, observer, 8) centerValue := planetOccultationExternalContactMetric(centerTT, config, observer, 8) results := make([]float64, 0) for centerTT < scanEnd { rightTT := math.Min(centerTT+step, scanEnd) rightValue := planetOccultationExternalContactMetric(rightTT, config, observer, 8) candidateLimit := planetOccultationCandidateLimitArcsec + safetyMarginArcsec if finite(leftValue) && finite(centerValue) && finite(rightValue) && centerValue <= leftValue && centerValue <= rightValue && centerValue <= candidateLimit { // 最小外接触度量同时包含动态月面和行星盘面,因此定义事件是否存在以及报告的掩甚时刻。 // The minimum outer-contact metric includes both dynamic disks and therefore defines event existence and the reported greatest instant. greatestTT := planetOccultationMinimizeExternalMetric(leftTT, rightTT, config, observer) if greatestTT >= startTT && greatestTT <= endTT && planetOccultationLatitudePass(greatestTT, config, observer, safetyMarginArcsec) { if len(results) == 0 || math.Abs(greatestTT-results[len(results)-1]) > 60.0/86400.0 { results = append(results, greatestTT) } } } leftTT, leftValue = centerTT, centerValue centerTT, centerValue = rightTT, rightValue } return results } func planetOccultationMinimizeExternalMetric(left, right float64, config planetOccultationConfig, observer *Observer) float64 { if right <= left { return left } const goldenRatio = 0.6180339887498949 x1 := right - goldenRatio*(right-left) x2 := left + goldenRatio*(right-left) f1 := planetOccultationExternalContactMetric(x1, config, observer, -1) f2 := planetOccultationExternalContactMetric(x2, config, observer, -1) for i := 0; i < 64 && right-left > planetOccultationRootToleranceDays; i++ { if f1 > f2 { left = x1 x1, f1 = x2, f2 x2 = left + goldenRatio*(right-left) f2 = planetOccultationExternalContactMetric(x2, config, observer, -1) } else { right = x2 x2, f2 = x1, f1 x1 = right - goldenRatio*(right-left) f1 = planetOccultationExternalContactMetric(x1, config, observer, -1) } } return (left + right) / 2 } func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planetOccultationConfig) (float64, Observer, float64, bool) { frameAt := func(tt float64) (occultationPathFrame, bool) { return planetOccultationPathFrameAt(tt, config) } searchStart := seedTT - occultationPathSearchSpanDays searchEnd := seedTT + occultationPathSearchSpanDays outerStart, outerEnd, ok := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, false) if !ok { return 0, Observer{}, 0, false } greatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, frameAt) if greatestTT < startTT || greatestTT > endTT { return 0, Observer{}, 0, false } point, pointOK := occultationPathCenterPointForFrame(greatestTT, frameAt, time.UTC) if !pointOK { point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC) } if !pointOK { return 0, Observer{}, 0, false } observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude} state := planetOccultationStateAt(greatestTT, config, &observer, -1) if !state.valid { return 0, Observer{}, 0, false } return greatestTT, observer, state.externalContactMetric, true } func planetOccultationInfoAtGreatest( greatestTT float64, config planetOccultationConfig, observer Observer, safetyMarginArcsec float64, location *time.Location, ) (PlanetOccultationInfo, bool) { if !planetOccultationLatitudePass(greatestTT, config, &observer, safetyMarginArcsec) { return PlanetOccultationInfo{}, false } state := planetOccultationStateAt(greatestTT, config, &observer, -1) if !state.valid || state.externalContactMetric > 0 { return PlanetOccultationInfo{}, false } info := PlanetOccultationInfo{ Planet: config.planet, TargetID: config.planet.String(), Observer: observer, Type: OccultationPartial, Greatest: occultationTTToLocation(greatestTT, location), MinimumSeparationArcsec: state.separationArcsec, PositionAngleDeg: occultationPositionAngle(state.position.moonRA, state.position.moonDec, state.position.planetRA, state.position.planetDec), MoonSemidiameterArcsec: state.moonSemidiameter, PlanetSemidiameterArcsec: state.planetSemidiameter, MoonAltitudeAtGreatest: occultationAltitude(greatestTT, observer, state.position.moonRA, state.position.moonDec), MoonAzimuthAtGreatest: occultationAzimuth(greatestTT, observer, state.position.moonRA, state.position.moonDec), } info.VisibleAtGreatest = info.MoonAltitudeAtGreatest >= 0 if math.Abs(state.externalContactMetric) <= planetOccultationGrazingToleranceArcsec { info.Type = OccultationGrazing info.ExternalImmersion = info.Greatest info.ExternalEmersion = info.Greatest info.ContactsComplete = true return info, true } externalImmersionTT, externalImmersionOK := planetOccultationContact(greatestTT, -1, false, config, observer) externalEmersionTT, externalEmersionOK := planetOccultationContact(greatestTT, 1, false, config, observer) if !externalImmersionOK || !externalEmersionOK || externalEmersionTT <= externalImmersionTT { return PlanetOccultationInfo{}, false } info.ExternalImmersion = occultationTTToLocation(externalImmersionTT, location) info.ExternalEmersion = occultationTTToLocation(externalEmersionTT, location) if state.internalContactMetric < -planetOccultationGrazingToleranceArcsec { internalImmersionTT, internalImmersionOK := planetOccultationContact(greatestTT, -1, true, config, observer) internalEmersionTT, internalEmersionOK := planetOccultationContact(greatestTT, 1, true, config, observer) if !internalImmersionOK || !internalEmersionOK || internalImmersionTT <= externalImmersionTT || internalEmersionTT >= externalEmersionTT || internalImmersionTT >= greatestTT || internalEmersionTT <= greatestTT { return PlanetOccultationInfo{}, false } info.Type = OccultationTotal info.InternalImmersion = occultationTTToLocation(internalImmersionTT, location) info.InternalEmersion = occultationTTToLocation(internalEmersionTT, location) info.HasInternalContacts = true } info.ContactsComplete = true return info, true } func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetMoonPosition { moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n) planetRA, planetDec := config.apparentRaDecN(tt, n) if observer != nil { ut := TD2UT(tt, false) moonDistanceAU := HMoonAwayN(tt, n) / angularDiameterAstronomicalUnitKM planetDistanceAU := config.earthDistanceN(tt, n) moonRA, moonDec = TopocentricRaDec(moonRA, moonDec, observer.Latitude, observer.Longitude, ut, moonDistanceAU, observer.Height) planetRA, planetDec = TopocentricRaDec(planetRA, planetDec, observer.Latitude, observer.Longitude, ut, planetDistanceAU, observer.Height) moonRA = normalizeRA(moonRA) planetRA = normalizeRA(planetRA) } return planetMoonPosition{ moonRA: moonRA, moonDec: moonDec, planetRA: planetRA, planetDec: planetDec, valid: finite(moonRA) && finite(moonDec) && finite(planetRA) && finite(planetDec), } } func planetOccultationStateAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetOccultationState { position := planetMoonPositionAt(tt, config, observer, n) moonRadius := MoonSemidiameterN(tt, n) planetRadius := config.semidiameterN(tt, n) if observer != nil { moonRadius = moonTopocentricSemidiameterN(tt, *observer, n) planetRadius = planetTopocentricSemidiameterN(tt, config, *observer, n) } if !position.valid || !finite(moonRadius) || !finite(planetRadius) || moonRadius <= planetRadius || planetRadius <= 0 { return planetOccultationState{} } separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600 return planetOccultationState{ position: position, separationArcsec: separation, moonSemidiameter: moonRadius, planetSemidiameter: planetRadius, externalContactMetric: separation - (moonRadius + planetRadius), internalContactMetric: separation - (moonRadius - planetRadius), valid: finite(separation), } } func planetTopocentricSemidiameterN(tt float64, config planetOccultationConfig, observer Observer, n int) float64 { ra, dec := config.apparentRaDecN(tt, n) distanceKM := config.earthDistanceN(tt, n) * angularDiameterAstronomicalUnitKM if !finite(ra) || !finite(dec) || !finite(distanceKM) || distanceKM <= 0 { return math.NaN() } distanceKM = topocentricDistanceKM(ra, dec, distanceKM, observer, TD2UT(tt, false)) if !finite(distanceKM) || distanceKM <= config.equatorialRadiusKM { return math.NaN() } return angularSemidiameterArcsec(config.equatorialRadiusKM, distanceKM) } func planetOccultationExternalContactMetric(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 { state := planetOccultationStateAt(tt, config, observer, n) if !state.valid { return math.Inf(1) } return state.externalContactMetric } func planetMoonSeparationArcsec(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 { position := planetMoonPositionAt(tt, config, observer, n) if !position.valid { return math.Inf(1) } return angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600 } func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64) bool { state := planetOccultationStateAt(tt, config, observer, -1) if !state.valid { return false } _, moonLatitude := RaDecToLoBo(tt, state.position.moonRA, state.position.moonDec) _, planetLatitude := RaDecToLoBo(tt, state.position.planetRA, state.position.planetDec) limit := state.moonSemidiameter + state.planetSemidiameter + planetOccultationLatitudeMarginArcsec + safetyMarginArcsec return math.Abs(moonLatitude-planetLatitude)*3600 <= limit } func planetOccultationContact( greatestTT float64, direction int, internal bool, config planetOccultationConfig, observer Observer, ) (float64, bool) { if direction != -1 && direction != 1 { return math.NaN(), false } metric := func(tt float64) float64 { state := planetOccultationStateAt(tt, config, &observer, -1) if !state.valid { return math.NaN() } if internal { return state.internalContactMetric } return state.externalContactMetric } nearTT := greatestTT nearValue := metric(nearTT) if !finite(nearValue) || nearValue > 0 { return math.NaN(), false } maxSteps := int(math.Ceil(planetOccultationContactSpanDays / planetOccultationContactStepDays)) if maxSteps > planetOccultationMaxContactSteps { maxSteps = planetOccultationMaxContactSteps } for i := 1; i <= maxSteps; i++ { farTT := greatestTT + float64(direction*i)*planetOccultationContactStepDays farValue := metric(farTT) if !finite(farValue) { continue } if farValue >= 0 { return planetOccultationRoot(nearTT, farTT, nearValue, farValue, metric) } nearTT, nearValue = farTT, farValue } return math.NaN(), false } func planetOccultationRoot( leftTT, rightTT, leftValue, rightValue float64, metric func(float64) float64, ) (float64, bool) { if leftTT > rightTT { leftTT, rightTT = rightTT, leftTT leftValue, rightValue = rightValue, leftValue } if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 { return math.NaN(), false } for i := 0; i < 64 && math.Abs(rightTT-leftTT) > planetOccultationRootToleranceDays; i++ { midTT := (leftTT + rightTT) / 2 midValue := metric(midTT) if !finite(midValue) { return math.NaN(), false } if leftValue*midValue <= 0 { rightTT, rightValue = midTT, midValue } else { leftTT, leftValue = midTT, midValue } } return (leftTT + rightTT) / 2, true } func planetOccultationCoarseStepDays(options OccultationSearchOptions) float64 { step := planetOccultationDefaultStepDays if options.MaxStep > 0 { requested := options.MaxStep.Hours() / 24 if requested > 0 && requested < step { step = requested } } return math.Max(step, occultationSearchMinimumStep.Hours()/24) }