PositionAngleDetector.java

/* Copyright 2002-2024 CS GROUP
 * Licensed to CS GROUP (CS) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * CS licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.orekit.propagation.events;

import java.util.function.Function;

import org.hipparchus.analysis.UnivariateFunction;
import org.hipparchus.analysis.solvers.BracketingNthOrderBrentSolver;
import org.hipparchus.util.FastMath;
import org.hipparchus.util.MathUtils;
import org.orekit.errors.OrekitIllegalArgumentException;
import org.orekit.errors.OrekitMessages;
import org.orekit.orbits.CircularOrbit;
import org.orekit.orbits.EquinoctialOrbit;
import org.orekit.orbits.KeplerianOrbit;
import org.orekit.orbits.Orbit;
import org.orekit.orbits.OrbitType;
import org.orekit.orbits.PositionAngleType;
import org.orekit.propagation.SpacecraftState;
import org.orekit.propagation.events.handlers.EventHandler;
import org.orekit.propagation.events.handlers.StopOnEvent;
import org.orekit.time.AbsoluteDate;
import org.orekit.utils.TimeSpanMap;

/** Detector for in-orbit position angle.
 * <p>
 * The detector is based on anomaly for {@link OrbitType#KEPLERIAN Keplerian}
 * orbits, latitude argument for {@link OrbitType#CIRCULAR circular} orbits,
 * or longitude argument for {@link OrbitType#EQUINOCTIAL equinoctial} orbits.
 * It does not support {@link OrbitType#CARTESIAN Cartesian} orbits. The
 * angles can be either {@link PositionAngleType#TRUE true}, {@link PositionAngleType#MEAN
 * mean} or {@link PositionAngleType#ECCENTRIC eccentric} angles.
 * </p>
 * @author Luc Maisonobe
 * @since 7.1
 */
public class PositionAngleDetector extends AbstractDetector<PositionAngleDetector> {

    /** Orbit type defining the angle type. */
    private final OrbitType orbitType;

    /** Type of position angle. */
    private final PositionAngleType positionAngleType;

    /** Fixed angle to be crossed. */
    private final double angle;

    /** Position angle extraction function. */
    private final Function<Orbit, Double> positionAngleExtractor;

    /** Estimators for the offset angle, taking care of 2π wrapping and g function continuity. */
    private TimeSpanMap<OffsetEstimator> offsetEstimators;

    /** Build a new detector.
     * <p>The new instance uses default values for maximal checking interval
     * ({@link #DEFAULT_MAX_CHECK}) and convergence threshold ({@link
     * #DEFAULT_THRESHOLD}).</p>
     * @param orbitType orbit type defining the angle type
     * @param positionAngleType type of position angle
     * @param angle fixed angle to be crossed
     * @exception OrekitIllegalArgumentException if orbit type is {@link OrbitType#CARTESIAN}
     */
    public PositionAngleDetector(final OrbitType orbitType, final PositionAngleType positionAngleType,
                                 final double angle)
        throws OrekitIllegalArgumentException {
        this(DEFAULT_MAX_CHECK, DEFAULT_THRESHOLD, orbitType, positionAngleType, angle);
    }

    /** Build a detector.
     * <p> This instance uses by default the {@link StopOnEvent} handler </p>
     * @param maxCheck maximal checking interval (s)
     * @param threshold convergence threshold (s)
     * @param orbitType orbit type defining the angle type
     * @param positionAngleType type of position angle
     * @param angle fixed angle to be crossed
     * @exception OrekitIllegalArgumentException if orbit type is {@link OrbitType#CARTESIAN}
     */
    public PositionAngleDetector(final double maxCheck, final double threshold,
                                 final OrbitType orbitType, final PositionAngleType positionAngleType,
                                 final double angle)
        throws OrekitIllegalArgumentException {
        this(new EventDetectionSettings(maxCheck, threshold, DEFAULT_MAX_ITER), new StopOnEvent(),
             orbitType, positionAngleType, angle);
    }

    /** Protected constructor with full parameters.
     * <p>
     * This constructor is not public as users are expected to use the builder
     * API with the various {@code withXxx()} methods to set up the instance
     * in a readable manner without using a huge amount of parameters.
     * </p>
     * @param detectionSettings event detection settings
     * @param handler event handler to call at event occurrences
     * @param orbitType orbit type defining the angle type
     * @param positionAngleType type of position angle
     * @param angle fixed angle to be crossed
     * @exception OrekitIllegalArgumentException if orbit type is {@link OrbitType#CARTESIAN}
     * @since 13.0
     */
    protected PositionAngleDetector(final EventDetectionSettings detectionSettings, final EventHandler handler,
                                    final OrbitType orbitType, final PositionAngleType positionAngleType,
                                    final double angle)
        throws OrekitIllegalArgumentException {

        super(detectionSettings, handler);

        this.orbitType        = orbitType;
        this.positionAngleType = positionAngleType;
        this.angle            = angle;
        this.offsetEstimators = null;

        switch (orbitType) {
            case KEPLERIAN:
                positionAngleExtractor = o -> ((KeplerianOrbit) orbitType.convertType(o)).getAnomaly(positionAngleType);
                break;
            case CIRCULAR:
                positionAngleExtractor = o -> ((CircularOrbit) orbitType.convertType(o)).getAlpha(positionAngleType);
                break;
            case EQUINOCTIAL:
                positionAngleExtractor = o -> ((EquinoctialOrbit) orbitType.convertType(o)).getL(positionAngleType);
                break;
            default:
                final String sep = ", ";
                throw new OrekitIllegalArgumentException(OrekitMessages.ORBIT_TYPE_NOT_ALLOWED,
                                                         orbitType,
                                                         OrbitType.KEPLERIAN   + sep +
                                                         OrbitType.CIRCULAR    + sep +
                                                         OrbitType.EQUINOCTIAL);
        }

    }

    /** {@inheritDoc} */
    @Override
    protected PositionAngleDetector create(final EventDetectionSettings detectionSettings,
                                           final EventHandler newHandler) {
        return new PositionAngleDetector(detectionSettings, newHandler, orbitType, positionAngleType, angle);
    }

    /** Get the orbit type defining the angle type.
     * @return orbit type defining the angle type
     */
    public OrbitType getOrbitType() {
        return orbitType;
    }

    /** Get the type of position angle.
     * @return type of position angle
     */
    public PositionAngleType getPositionAngleType() {
        return positionAngleType;
    }

    /** Get the fixed angle to be crossed (radians).
     * @return fixed angle to be crossed (radians)
     */
    public double getAngle() {
        return angle;
    }

    /** {@inheritDoc} */
    @Override
    public void init(final SpacecraftState s0, final AbsoluteDate t) {
        super.init(s0, t);
        offsetEstimators = new TimeSpanMap<>(new OffsetEstimator(s0.getOrbit(), +1.0));
    }

    /** Compute the value of the detection function.
     * <p>
     * The value is the angle difference between the spacecraft and the fixed
     * angle to be crossed, with some sign tweaks to ensure continuity.
     * These tweaks imply the {@code increasing} flag in events detection becomes
     * irrelevant here! As an example, the angle always increase in a Keplerian
     * orbit, but this g function will increase and decrease so it
     * will cross the zero value once per orbit, in increasing and decreasing
     * directions on alternate orbits..
     * </p>
     * @param s the current state information: date, kinematics, attitude
     * @return angle difference between the spacecraft and the fixed
     * angle, with some sign tweaks to ensure continuity
     */
    public double g(final SpacecraftState s) {

        final Orbit orbit = s.getOrbit();

        // angle difference
        OffsetEstimator estimator = offsetEstimators.get(s.getDate());
        double          delta     = estimator.delta(orbit);

        // we use a value greater than π for handover in order to avoid
        // several switches to be estimated as the calling propagator
        // and Orbit.shiftedBy have different accuracy. It is sufficient
        // to have a handover roughly opposite to the detected position angle
        while (FastMath.abs(delta) >= 3.5) {
            // we are too far away from the current estimator, we need to set up a new one
            // ensuring that we do have a crossing event in the current orbit
            // and we ensure sign continuity with the current estimator

            // find when the previous estimator becomes invalid
            final AbsoluteDate handover = estimator.dateForOffset(FastMath.copySign(FastMath.PI, delta), orbit);

            // perform handover to a new estimator at this date
            estimator = new OffsetEstimator(orbit, delta);
            delta     = estimator.delta(orbit);
            if (isForward()) {
                offsetEstimators.addValidAfter(estimator, handover.getDate(), false);
            } else {
                offsetEstimators.addValidBefore(estimator, handover.getDate(), false);
            }

        }

        return delta;

    }

    /** Local class for estimating offset angle, handling 2π wrap-up and sign continuity. */
    private class OffsetEstimator {

        /** Target angle. */
        private final double target;

        /** Sign correction to offset. */
        private final double sign;

        /** Reference angle. */
        private final double r0;

        /** Slope of the linearized model. */
        private final double r1;

        /** Reference date. */
        private final AbsoluteDate t0;

        /** Simple constructor.
         * @param orbit current orbit
         * @param currentSign desired sign of the offset at current orbit time (magnitude is ignored)
         */
        OffsetEstimator(final Orbit orbit, final double currentSign) {
            r0     = positionAngleExtractor.apply(orbit);
            target = MathUtils.normalizeAngle(angle, r0);
            sign   = FastMath.copySign(1.0, (r0 - target) * currentSign);
            r1     = orbit.getKeplerianMeanMotion();
            t0     = orbit.getDate();
        }

        /** Compute offset from reference angle.
         * @param orbit current orbit
         * @return offset between current angle and reference angle
         */
        public double delta(final Orbit orbit) {
            final double rawAngle        = positionAngleExtractor.apply(orbit);
            final double linearReference = r0 + r1 * orbit.getDate().durationFrom(t0);
            final double linearizedAngle = MathUtils.normalizeAngle(rawAngle, linearReference);
            return sign * (linearizedAngle - target);
        }

        /** Find date at which offset reaches specified value.
         * <p>
         * This computation is an approximation because it relies on
         * {@link Orbit#shiftedBy(double)} only.
         * </p>
         * @param offset target value for offset angle
         * @param orbit current orbit
         * @return approximate date at which offset reached specified value
         */
        public AbsoluteDate dateForOffset(final double offset, final Orbit orbit) {

            // bracket the search
            final double period = orbit.getKeplerianPeriod();
            final double delta0 = delta(orbit);
            final double searchInf;
            final double searchSup;
            if ((delta0 - offset) * sign >= 0) {
                // the date is before current orbit
                searchInf = -period;
                searchSup = 0;
            } else {
                // the date is after current orbit
                searchInf = 0;
                searchSup = +period;
            }

            // find the date as an offset from current orbit
            final BracketingNthOrderBrentSolver solver = new BracketingNthOrderBrentSolver(getThreshold(), 5);
            final UnivariateFunction            f      = dt -> delta(orbit.shiftedBy(dt)) - offset;
            final double                        root   = solver.solve(getMaxIterationCount(), f, searchInf, searchSup);

            return orbit.getDate().shiftedBy(root);

        }

    }

}