package basic import ( "math" "time" ) func refineOccultationRiseSetFold( first, second OccultationPathPoint, atStart bool, greatest bool, stepDays float64, location *time.Location, cache *occultationRiseSetEvaluationCache, ) (OccultationPathPoint, bool) { newton := func() (OccultationPathPoint, bool) { seedTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2 firstAngle := occultationRiseSetHorizonAngle(seedTT, first.Longitude, first.Latitude, cache.context) secondAngle := occultationRiseSetHorizonAngle(seedTT, second.Longitude, second.Latitude, cache.context) coordinates := [2]float64{riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2), 0} const angleStep = 1e-4 for iteration := 0; iteration < 32; iteration++ { residual, ok := occultationRiseSetFoldHorizonResidual(seedTT, coordinates, greatest, cache) if !ok { return OccultationPathPoint{}, false } if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 { break } plusAngle, plusAngleOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0] + angleStep, coordinates[1]}, greatest, cache) minusAngle, minusAngleOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0] - angleStep, coordinates[1]}, greatest, cache) timeStep := 1.0 / 60.0 plusTime, plusTimeOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0], coordinates[1] + timeStep}, greatest, cache) minusTime, minusTimeOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0], coordinates[1] - timeStep}, greatest, cache) if !plusAngleOK || !minusAngleOK || !plusTimeOK || !minusTimeOK { return OccultationPathPoint{}, false } matrix := [2][2]float64{ {(plusAngle[0] - minusAngle[0]) / (2 * angleStep), (plusTime[0] - minusTime[0]) / (2 * timeStep)}, {(plusAngle[1] - minusAngle[1]) / (2 * angleStep), (plusTime[1] - minusTime[1]) / (2 * timeStep)}, } determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0] if !finite(determinant) || math.Abs(determinant) < 1e-18 { return OccultationPathPoint{}, false } delta := [2]float64{ (-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant, (-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant, } if math.Abs(delta[0]) > 0.25 { delta[0] = math.Copysign(0.25, delta[0]) } if math.Abs(delta[1]) > 5 { delta[1] = math.Copysign(5, delta[1]) } coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) coordinates[1] += delta[1] } jd := seedTT + coordinates[1]/1440 longitude, latitude, ok := occultationRiseSetHorizonPoint(jd, coordinates[0], cache.context) if !ok { return OccultationPathPoint{}, false } return refineOccultationRiseSetFoldPoint(jd, longitude, latitude, greatest, location, cache) } if point, ok := newton(); ok { return point, true } return refineOccultationRiseSetFoldBracketed( first, second, atStart, greatest, stepDays, location, cache, ) } func refineOccultationRiseSetFoldBracketed( first, second OccultationPathPoint, atStart bool, greatest bool, stepDays float64, location *time.Location, cache *occultationRiseSetEvaluationCache, ) (OccultationPathPoint, bool) { sampleTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2 firstAngle := occultationRiseSetHorizonAngle(sampleTT, first.Longitude, first.Latitude, cache.context) secondAngle := occultationRiseSetHorizonAngle(sampleTT, second.Longitude, second.Latitude, cache.context) sampleAngle := riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2) sampleAngle, sampleValue, ok := occultationRiseSetFoldExtremumAt(sampleTT, sampleAngle, greatest, cache) if !ok { return OccultationPathPoint{}, false } direction := 1.0 if atStart { direction = -1 } var otherTT, otherAngle, otherValue float64 bracketed := false for step := 1; step <= 4; step++ { otherTT = sampleTT + direction*float64(step)*stepDays otherAngle, otherValue, ok = occultationRiseSetFoldExtremumAt(otherTT, sampleAngle, greatest, cache) if !ok { continue } if sampleValue == 0 || otherValue == 0 || sampleValue*otherValue < 0 { bracketed = true break } } if !bracketed { return OccultationPathPoint{}, false } leftTT, leftAngle, leftValue := sampleTT, sampleAngle, sampleValue rightTT, rightAngle, rightValue := otherTT, otherAngle, otherValue for iteration := 0; iteration < 64 && math.Abs(rightTT-leftTT) > occultationRiseSetFoldRootToleranceDays; iteration++ { middleTT := (leftTT + rightTT) / 2 middleSeed := riseSetNormalizeRadians(leftAngle + math.Remainder(rightAngle-leftAngle, 2*math.Pi)/2) middleAngle, middleValue, middleOK := occultationRiseSetFoldExtremumAt(middleTT, middleSeed, greatest, cache) if !middleOK { return OccultationPathPoint{}, false } if leftValue == 0 || leftValue*middleValue <= 0 { rightTT, rightAngle, rightValue = middleTT, middleAngle, middleValue } else { leftTT, leftAngle, leftValue = middleTT, middleAngle, middleValue } } _ = rightValue rootTT := (leftTT + rightTT) / 2 rootSeed := riseSetNormalizeRadians(leftAngle + math.Remainder(rightAngle-leftAngle, 2*math.Pi)/2) rootAngle, rootValue, ok := occultationRiseSetFoldExtremumAt(rootTT, rootSeed, greatest, cache) if !ok { return OccultationPathPoint{}, false } longitude, latitude, ok := occultationRiseSetHorizonPoint(rootTT, rootAngle, cache.context) if !ok { return OccultationPathPoint{}, false } if point, refined := refineOccultationRiseSetFoldPoint(rootTT, longitude, latitude, greatest, location, cache); refined { return point, true } state := cache.context(rootTT).stateAt(longitude, latitude) if !state.valid || math.Abs(rootValue) > 1e-7 || math.Abs(state.moonAltitude) > 1e-8 { return OccultationPathPoint{}, false } return OccultationPathPoint{ Time: occultationTTToLocation(rootTT, location), Longitude: longitude, Latitude: latitude, MoonAltitude: state.moonAltitude, }, true } func occultationRiseSetFoldExtremumAt( tt, angle float64, greatest bool, cache *occultationRiseSetEvaluationCache, ) (float64, float64, bool) { const angleStep = 1e-4 angle = riseSetNormalizeRadians(angle) for iteration := 0; iteration < 24; iteration++ { residual, ok := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle, 0}, greatest, cache) if !ok { return 0, 0, false } if math.Abs(residual[1]) <= 1e-10 { return angle, residual[0], true } before, beforeOK := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle - angleStep, 0}, greatest, cache) after, afterOK := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle + angleStep, 0}, greatest, cache) if !beforeOK || !afterOK { return 0, 0, false } secondDerivative := (after[1] - before[1]) / (2 * angleStep) if !finite(secondDerivative) || math.Abs(secondDerivative) < 1e-16 { return 0, 0, false } delta := -residual[1] / secondDerivative if math.Abs(delta) > 0.25 { delta = math.Copysign(0.25, delta) } angle = riseSetNormalizeRadians(angle + delta) } residual, ok := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle, 0}, greatest, cache) return angle, residual[0], ok && finite(residual[0]) && finite(residual[1]) && math.Abs(residual[1]) <= 1e-7 } func occultationRiseSetHorizonCenter(context occultationRiseSetContext) (float64, float64) { return normalizeLongitude(context.moonRA - context.siderealDegrees), context.moonDec } func occultationRiseSetHorizonPoint( tt, angle float64, contextAt occultationRiseSetContextFunc, ) (float64, float64, bool) { return occultationRiseSetHorizonPointFromContext(contextAt(tt), angle) } func occultationRiseSetHorizonPointFromContext( context occultationRiseSetContext, angle float64, ) (float64, float64, bool) { if !context.valid { return 0, 0, false } center, first, second, basisOK := occultationRiseSetHorizonBasis(context) if !basisOK { return 0, 0, false } tangent := occultationRiseSetCircleTangent(first, second, angle) pointAt := func(radius float64) (float64, float64, float64, bool) { longitude, latitude := occultationRiseSetCirclePointFromTangent(center, tangent, radius) residual, ok := context.moonHorizonResidual(longitude, latitude) return longitude, latitude, residual, ok } left, right := 80*rad, 100*rad _, _, leftValue, leftOK := pointAt(left) _, _, rightValue, rightOK := pointAt(right) if !leftOK || !rightOK || leftValue*rightValue > 0 { return 0, 0, false } weightedLeft, weightedRight := leftValue, rightValue lastSide := 0 for iteration := 0; iteration < 12; iteration++ { denominator := weightedRight - weightedLeft middle := (left + right) / 2 if finite(denominator) && math.Abs(denominator) > 1e-18 { candidate := (left*weightedRight - right*weightedLeft) / denominator if candidate > left && candidate < right { middle = candidate } } longitude, latitude, middleValue, middleOK := pointAt(middle) if !middleOK { return 0, 0, false } if math.Abs(middleValue) <= 1e-6 { return longitude, latitude, true } if leftValue*middleValue <= 0 { right, rightValue, weightedRight = middle, middleValue, middleValue if lastSide < 0 { weightedLeft *= 0.5 } else { weightedLeft = leftValue } lastSide = -1 } else { left, leftValue, weightedLeft = middle, middleValue, middleValue if lastSide > 0 { weightedRight *= 0.5 } else { weightedRight = rightValue } lastSide = 1 } } for iteration := 0; iteration < 32; iteration++ { middle := (left + right) / 2 _, _, middleValue, middleOK := pointAt(middle) if !middleOK { return 0, 0, false } if leftValue*middleValue <= 0 { right, rightValue = middle, middleValue } else { left, leftValue = middle, middleValue } } longitude, latitude, _, ok := pointAt((left + right) / 2) return longitude, latitude, ok } func occultationRiseSetHorizonBasis(context occultationRiseSetContext) ([3]float64, [3]float64, [3]float64, bool) { centerLongitude, centerLatitude := occultationRiseSetHorizonCenter(context) longitude := centerLongitude * rad latitude := centerLatitude * rad center := [3]float64{ math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude), } reference := [3]float64{0, 0, 1} if math.Abs(center[2]) > 0.9 { reference = [3]float64{1, 0, 0} } first := riseSetUnitVector(riseSetCross(reference, center)) second := riseSetUnitVector(riseSetCross(center, first)) return center, first, second, finite(center[0]) && finite(first[0]) && finite(second[0]) } func occultationRiseSetCirclePointFromBasis(center, first, second [3]float64, angle, radius float64) (float64, float64) { return occultationRiseSetCirclePointFromTangent( center, occultationRiseSetCircleTangent(first, second, angle), radius, ) } func occultationRiseSetCircleTangent(first, second [3]float64, angle float64) [3]float64 { sinAngle, cosAngle := math.Sincos(angle) return [3]float64{ first[0]*cosAngle + second[0]*sinAngle, first[1]*cosAngle + second[1]*sinAngle, first[2]*cosAngle + second[2]*sinAngle, } } func occultationRiseSetCirclePointFromTangent(center, tangent [3]float64, radius float64) (float64, float64) { sinRadius, cosRadius := math.Sincos(radius) point := [3]float64{ center[0]*cosRadius + tangent[0]*sinRadius, center[1]*cosRadius + tangent[1]*sinRadius, center[2]*cosRadius + tangent[2]*sinRadius, } return normalizeLongitude(math.Atan2(point[1], point[0]) / rad), math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad } func occultationRiseSetHorizonAngle(tt, longitude, latitude float64, contextAt occultationRiseSetContextFunc) float64 { _, first, second, ok := occultationRiseSetHorizonBasis(contextAt(tt)) if !ok { return math.NaN() } pointLon, pointLat := longitude*rad, latitude*rad point := [3]float64{math.Cos(pointLat) * math.Cos(pointLon), math.Cos(pointLat) * math.Sin(pointLon), math.Sin(pointLat)} return riseSetNormalizeRadians(math.Atan2(point[0]*second[0]+point[1]*second[1]+point[2]*second[2], point[0]*first[0]+point[1]*first[1]+point[2]*first[2])) } func occultationRiseSetFoldHorizonResidual(seedTT float64, coordinates [2]float64, greatest bool, cache *occultationRiseSetEvaluationCache) ([2]float64, bool) { jd := seedTT + coordinates[1]/1440 evaluation := cache.evaluation(jd) valueAt := func(angle float64) (float64, bool) { longitude, latitude, ok := occultationRiseSetHorizonPoint(jd, angle, cache.context) if !ok { return 0, false } return occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) } center, centerOK := valueAt(coordinates[0]) before, beforeOK := valueAt(coordinates[0] - 1e-4) after, afterOK := valueAt(coordinates[0] + 1e-4) return [2]float64{center, (after - before) / (2e-4)}, centerOK && beforeOK && afterOK && finite(center) && finite(before) && finite(after) } func refineOccultationRiseSetFoldPoint( tt, longitude, latitude float64, greatest bool, location *time.Location, cache *occultationRiseSetEvaluationCache, ) (OccultationPathPoint, bool) { coordinates := [3]float64{longitude, latitude, tt} for iteration := 0; iteration < 32; iteration++ { evaluation := cache.evaluation(coordinates[2]) residual, ok := occultationRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest) if !ok { return OccultationPathPoint{}, false } if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 { break } steps := [3]float64{1e-3, 1e-3, 1.0 / 86400.0} matrix := [3][3]float64{} for column := range steps { plus, minus := coordinates, coordinates plus[column] += steps[column] minus[column] -= steps[column] plusEval := cache.evaluation(plus[2]) minusEval := cache.evaluation(minus[2]) plusResidual, plusOK := occultationRiseSetFoldResidualAt(plusEval, plus[0], plus[1], greatest) minusResidual, minusOK := occultationRiseSetFoldResidualAt(minusEval, minus[0], minus[1], greatest) if !plusOK || !minusOK { return OccultationPathPoint{}, false } for row := range matrix { matrix[row][column] = (plusResidual[row] - minusResidual[row]) / (2 * steps[column]) } } delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) if !ok { return OccultationPathPoint{}, false } geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) if geographicScale > 2 { delta[0] *= 2 / geographicScale delta[1] *= 2 / geographicScale } if math.Abs(delta[2]) > 2.0/1440 { delta[2] = math.Copysign(2.0/1440, delta[2]) } for index := range coordinates { coordinates[index] += delta[index] } coordinates[0] = normalizeLongitude(coordinates[0]) if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { return OccultationPathPoint{}, false } } evaluation := cache.evaluation(coordinates[2]) residual, ok := occultationRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest) if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { return OccultationPathPoint{}, false } state := evaluation.center.stateAt(coordinates[0], coordinates[1]) return OccultationPathPoint{Time: occultationTTToLocation(coordinates[2], location), Longitude: coordinates[0], Latitude: coordinates[1], MoonAltitude: state.moonAltitude}, true } func occultationRiseSetFoldResidualAt(evaluation occultationRiseSetEvaluation, longitude, latitude float64, greatest bool) ([3]float64, bool) { valueAt := func(lon, lat float64) ([2]float64, bool) { first, ok := occultationRiseSetPhaseResidual(evaluation, lon, lat, greatest) state := evaluation.center.stateAt(lon, lat) return [2]float64{first, state.moonAltitude}, ok && state.valid } center, centerOK := valueAt(longitude, latitude) lonPlus, lonPlusOK := valueAt(longitude+1e-3, latitude) lonMinus, lonMinusOK := valueAt(longitude-1e-3, latitude) latPlus, latPlusOK := valueAt(longitude, latitude+1e-3) latMinus, latMinusOK := valueAt(longitude, latitude-1e-3) if !centerOK || !lonPlusOK || !lonMinusOK || !latPlusOK || !latMinusOK { return [3]float64{}, false } firstLon := (lonPlus[0] - lonMinus[0]) / 2e-3 firstLat := (latPlus[0] - latMinus[0]) / 2e-3 altitudeLon := (lonPlus[1] - lonMinus[1]) / 2e-3 altitudeLat := (latPlus[1] - latMinus[1]) / 2e-3 residual := [3]float64{center[0], center[1], firstLon*altitudeLat - firstLat*altitudeLon} return residual, finite(residual[2]) } func refineOccultationRiseSetCurveSpacing(curve *OccultationRiseSetCurve, location *time.Location, cache *occultationRiseSetEvaluationCache) { if curve == nil { return } for index, segment := range curve.Segments { if len(segment) < 2 { continue } refined := make([]OccultationPathPoint, 1, len(segment)) refined[0] = segment[0] for pointIndex := 1; pointIndex < len(segment); pointIndex++ { refined = appendRefinedOccultationRiseSetSegment(refined, segment[pointIndex-1], segment[pointIndex], curve.Phase, curve.Direction, location, cache, 0) } curve.Segments[index] = refined } } func appendRefinedOccultationRiseSetSegment( points []OccultationPathPoint, start, end OccultationPathPoint, phase RiseSetPhase, direction RiseSetDirection, location *time.Location, cache *occultationRiseSetEvaluationCache, depth int, ) []OccultationPathPoint { if occultationPathDistanceKM(start, end) <= occultationRiseSetProjectedTargetSpacingKM(start, end) || depth >= 12 { return append(points, end) } middle, ok := occultationRiseSetPhaseMidpoint( start, end, phase, direction, location, cache, ) if !ok { return append(points, end) } points = appendRefinedOccultationRiseSetSegment(points, start, middle, phase, direction, location, cache, depth+1) return appendRefinedOccultationRiseSetSegment(points, middle, end, phase, direction, location, cache, depth+1) } func occultationRiseSetPhaseMidpoint( start, end OccultationPathPoint, phase RiseSetPhase, direction RiseSetDirection, location *time.Location, cache *occultationRiseSetEvaluationCache, ) (OccultationPathPoint, bool) { startTT, endTT := occultationTimeToTT(start.Time), occultationTimeToTT(end.Time) tt := (startTT + endTT) / 2 startAngle := occultationRiseSetHorizonAngle(tt, start.Longitude, start.Latitude, cache.context) endAngle := occultationRiseSetHorizonAngle(tt, end.Longitude, end.Latitude, cache.context) seedAngle := riseSetNormalizeRadians(startAngle + math.Remainder(endAngle-startAngle, 2*math.Pi)/2) if middle, ok := occultationRiseSetPhasePointOnHorizon( tt, seedAngle, phase, direction, location, cache, ); ok && occultationRiseSetRefinementPointIsContinuous(start, middle, end) { return middle, true } longitude := normalizeLongitude(start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2) latitude := (start.Latitude + end.Latitude) / 2 evaluation := cache.evaluation(tt) longitude, latitude, ok := riseSetRefineGeographicRoot(longitude, latitude, func(lon, lat float64) (float64, float64, bool) { first, valid := occultationRiseSetPhaseResidual(evaluation, lon, lat, phase == RiseSetPhaseGreatest) state := evaluation.center.stateAt(lon, lat) return first, state.moonAltitude, valid && state.valid }) if !ok { return OccultationPathPoint{}, false } middle := OccultationPathPoint{Time: occultationTTToLocation(tt, location), Longitude: longitude, Latitude: latitude, MoonAltitude: evaluation.center.stateAt(longitude, latitude).moonAltitude} state := evaluation.center.stateAt(longitude, latitude) keyPoint, key, valid := evaluation.classify(longitude, latitude, phase == RiseSetPhaseGreatest, location) if valid { middle = keyPoint } if !valid || key.phase != phase || key.direction != direction || !state.valid || !occultationRiseSetRefinementPointIsContinuous(start, middle, end) { return OccultationPathPoint{}, false } return middle, true } // refineOccultationRiseSetPhaseJunctionApproaches samples the last two edges // approaching a shared start/greatest/end junction at the exact implicit // F=0,H=0 solution. A phase curve can turn rapidly there even when the final // endpoint is only a few kilometres away; ordinary distance-only sampling // otherwise renders that physical bend as one visible corner. func refineOccultationRiseSetPhaseJunctionApproaches( curves []OccultationRiseSetCurve, location *time.Location, cache *occultationRiseSetEvaluationCache, ) { for curveIndex := range curves { curve := &curves[curveIndex] for segmentIndex, segment := range curve.Segments { if len(segment) < 2 { continue } for _, atStart := range []bool{true, false} { endpoint := occultationRiseSetSegmentEndpoint(segment, atStart) if !occultationRiseSetEndpointSharesPhaseJunction(curves, curveIndex, endpoint) { continue } segment = refineOccultationRiseSetJunctionSegment( segment, atStart, curve.Phase, curve.Direction, location, cache, ) } curve.Segments[segmentIndex] = segment } } } func occultationRiseSetEndpointSharesPhaseJunction( curves []OccultationRiseSetCurve, curveIndex int, endpoint OccultationPathPoint, ) bool { for otherIndex, curve := range curves { if otherIndex == curveIndex || curve.Phase == curves[curveIndex].Phase || curve.Direction != curves[curveIndex].Direction { continue } for _, segment := range curve.Segments { if len(segment) == 0 { continue } for _, other := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { if math.Abs(occultationTimeToTT(endpoint.Time)-occultationTimeToTT(other.Time))*86400 <= 1 && occultationPathDistanceKM(endpoint, other) <= 0.01 { return true } } } } return false } func refineOccultationRiseSetJunctionSegment( segment []OccultationPathPoint, atStart bool, phase RiseSetPhase, direction RiseSetDirection, location *time.Location, cache *occultationRiseSetEvaluationCache, ) []OccultationPathPoint { if len(segment) < 3 { return segment } firstEdge, lastEdge := 0, len(segment)-1 if atStart { if lastEdge > 2 { lastEdge = 2 } } else { firstEdge = lastEdge - 2 if firstEdge < 0 { firstEdge = 0 } } result := make([]OccultationPathPoint, 1, len(segment)+16) result[0] = segment[0] for edgeIndex := 0; edgeIndex < len(segment)-1; edgeIndex++ { if edgeIndex >= firstEdge && edgeIndex < lastEdge { result = appendRefinedOccultationRiseSetJunction( result, segment[edgeIndex], segment[edgeIndex+1], phase, direction, location, cache, 0, ) continue } result = append(result, segment[edgeIndex+1]) } return result } func occultationRiseSetTurnAngleDegrees( first, middle, last OccultationPathPoint, ) float64 { longitudeScale := math.Cos(middle.Latitude * rad) firstX := math.Remainder(first.Longitude-middle.Longitude, 360) * longitudeScale firstY := first.Latitude - middle.Latitude lastX := math.Remainder(last.Longitude-middle.Longitude, 360) * longitudeScale lastY := last.Latitude - middle.Latitude firstLength := math.Hypot(firstX, firstY) lastLength := math.Hypot(lastX, lastY) if firstLength <= 1e-12 || lastLength <= 1e-12 { return 180 } cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) return math.Acos(math.Max(-1, math.Min(1, cosine))) / rad } func appendRefinedOccultationRiseSetJunction( points []OccultationPathPoint, start, end OccultationPathPoint, phase RiseSetPhase, direction RiseSetDirection, location *time.Location, cache *occultationRiseSetEvaluationCache, depth int, ) []OccultationPathPoint { if occultationPathDistanceKM(start, end) <= 0.05 || depth >= 12 { return append(points, end) } middle, ok := occultationRiseSetPhaseMidpoint(start, end, phase, direction, location, cache) if !ok { return append(points, end) } // A short chord can still miss the rapidly turning physical arc at the // contact-envelope junction. Refine its measured sagitta, not just length. if planetOccultationPointSegmentDistanceKM(middle, start, end) <= 0.025 { return append(points, end) } points = appendRefinedOccultationRiseSetJunction( points, start, middle, phase, direction, location, cache, depth+1, ) return appendRefinedOccultationRiseSetJunction( points, middle, end, phase, direction, location, cache, depth+1, ) } func occultationRiseSetProjectedTargetSpacingKM(start, end OccultationPathPoint) float64 { latitude := math.Max(math.Abs(start.Latitude), math.Abs(end.Latitude)) projectionScale := math.Cos(math.Min(latitude, 85) * rad) return occultationRiseSetTargetSpacingKM * math.Max(0.1, projectionScale) } func occultationRiseSetRefinementPointIsContinuous( start, middle, end OccultationPathPoint, ) bool { span := occultationPathDistanceKM(start, end) if !finite(span) { return false } // Newton can converge to the sibling root when the horizon contour folds. // A valid midpoint must remain in the local spatial neighbourhood of both // endpoints; the generous floor covers coarse samples while rejecting a // cross-polar branch jump. tolerance := math.Max(500, 2.5*span) return occultationPathDistanceKM(start, middle) <= tolerance && occultationPathDistanceKM(middle, end) <= tolerance } func occultationRiseSetPhasePointOnHorizon( tt, angle float64, phase RiseSetPhase, direction RiseSetDirection, location *time.Location, cache *occultationRiseSetEvaluationCache, ) (OccultationPathPoint, bool) { point, evaluation, ok := occultationRiseSetRawPhasePointOnHorizon( tt, angle, phase == RiseSetPhaseGreatest, location, cache, ) if !ok { return OccultationPathPoint{}, false } classified, key, valid := evaluation.classify( point.Longitude, point.Latitude, phase == RiseSetPhaseGreatest, location, ) if !valid || key.phase != phase || key.direction != direction { return OccultationPathPoint{}, false } return classified, true } func occultationRiseSetRawPhasePointOnHorizon( tt, angle float64, greatest bool, location *time.Location, cache *occultationRiseSetEvaluationCache, ) (OccultationPathPoint, occultationRiseSetEvaluation, bool) { evaluation := cache.evaluation(tt) valueAt := func(candidateAngle float64) (float64, float64, float64, bool) { longitude, latitude, horizonOK := occultationRiseSetHorizonPoint(tt, candidateAngle, cache.context) if !horizonOK { return 0, 0, 0, false } value, valueOK := occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) return value, longitude, latitude, valueOK && finite(value) } const angleStep = 1e-4 angle = riseSetNormalizeRadians(angle) for iteration := 0; iteration < 24; iteration++ { value, _, _, ok := valueAt(angle) if !ok { return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false } if math.Abs(value) <= 1e-10 { break } before, _, _, beforeOK := valueAt(angle - angleStep) after, _, _, afterOK := valueAt(angle + angleStep) if !beforeOK || !afterOK { return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false } derivative := (after - before) / (2 * angleStep) if !finite(derivative) || math.Abs(derivative) < 1e-16 { return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false } delta := -value / derivative if math.Abs(delta) > 0.25 { delta = math.Copysign(0.25, delta) } angle = riseSetNormalizeRadians(angle + delta) } value, longitude, latitude, ok := valueAt(angle) if !ok || math.Abs(value) > 1e-7 { return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false } state := evaluation.center.stateAt(longitude, latitude) if !state.valid { return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false } return OccultationPathPoint{ Time: occultationTTToLocation(tt, location), Longitude: longitude, Latitude: latitude, MoonAltitude: state.moonAltitude, }, evaluation, true }