Low Thrust 'Hohmann' Transfer

I’m currently trying to put together a Hohmann like low thrust/EP type maneuver to raise my orbit after drag and gravity have caused my orbit to decay slightly. I’m using the NLRMSIS atmosphere, a simple drag model, and a simple gravity model. I have a basic thruster model with defined low thrust (<1 N) and ISP. To detect all the different parts, I’m using an enum and state class:

public enum BurnPhase {
    IDLE,
    WAIT_PERIGEE,
    WAIT_APOGEE
}

public class GuidanceState {
    public boolean belowThreshold = false;
    public BurnPhase phase = BurnPhase.IDLE;
}

If I detect that the semi-major axis has fallen below a threshold value, then guidanceState.phase = BurnPhase.WAIT_PERIGEE. Then the next time I’m at perigee, a perigeeHandler determines the deltaV needed to achieve a circular orbit of the desired altitude and the burn duration needed to achieve that deltaV. I then add a ConstantThrustManuever to the propagator.

double aTrans = (perigeePosNorm + desiredRad) / 2;
double vTrans = FastMath.sqrt(2 * mu / perigeePosNorm - mu / aTrans);
double dvPerigee = vTrans - perigeeVelNorm;
double perigeeBurnTime = dvPerigee * mass / thrust;
ConstantThrustManeuver perigeeBurn = new ConstantThrustManeuver(startPerigeeBurn, perigeeBurnTime, thrust, isp, Vector3D.PLUS_I);
propagator.addForceModel(perigeeBurn);
guidanceState.phase = BurnPhase.WAIT_APOGEE;

Then, the next time I’m at apogee, I perform a similar burn, but this time just to circularize the orbit.

double vTrans = FastMath.sqrt(mu / apogeePosNorm); 
double dvApogee = vTrans - apogeeVelNorm;
double apogeeBurnTime = dvApogee * mass / thrust;
ConstantThrustManeuver apogeeBurn = new ConstantThrustManeuver(startApogeeBurn, apogeeBurnTime, thrust, isp, Vector3D.PLUS_I);
propagator.addForceModel(apogeeBurn);

guidanceState.phase = BurnPhase.IDLE;
guidanceState.belowThreshold = false;

I have some debugging print lines to verify everything is working so I’m printing out the force models that the propagator has access to at every time step. Right after perigee is detected after the spacecraft falls below the threshold SMA, the ConstantThrustManuever is added. However, it seems like the acceleration is zero even though all the inputs to the ConstantThrustManuever are nonzero.

   Reached Perigee, Burn Scheduled.
   --- ConstantThrustManuever(2025-12-29T06:57:53.94Z, 97.35, 0.60, 3000.0, Vector3D.PLUS_I)

t=2025-12-29T06:58:1.10Z, ForceModel active: DragModel, acc=2.60e-04 m/s2
t=2025-12-29T06:58:1.10Z, ForceModel active: HolmesFeatherstoneAttractionModel, acc=0.00e+00 m/s2
t=2025-12-29T06:58:1.10Z, ForceModel active: ConstantThrustManeuver, acc=0.00e+00 m/s2
t=2025-12-29T06:58:1.10Z, ForceModel active: NewtonianAttraction, acc=9.30e+00 m/s2

t=2025-12-29T06:58:11.10Z, ForceModel active: DragModel, acc=2.60e-04 m/s2
t=2025-12-29T06:58:11.10Z, ForceModel active: HolmesFeatherstoneAttractionModel, acc=0.00e+00 m/s2
t=2025-12-29T06:58:11.10Z, ForceModel active: ConstantThrustManeuver, acc=0.00e+00 m/s2
t=2025-12-29T06:58:11.10Z, ForceModel active: NewtonianAttraction, acc=9.30e+00 m/s2

Looking at the output of my SMA and instantaneous altitude, it seems like zero force/acceleration is being applied. Does anyone have any idea what could be causing this? Any suggestions would be greatly appreciated!

Hello @sswen20,

Would it be possible for you to provide a code sample to reproduce the issue ?

Cheers,
Vincent

I can, one quick check before adding as the code I have is quite long: I changed my Handler to stop propagation when I’ve fallen below the SMA threshold AND I’m at perigee. Then once propagation is stopped, I add the ConstantThrustManuever and restart propagation, setting the propagator initial state as the current SpacecraftState. When I do this, it seems to work just fine. Does that mean that a ConstantThrustManuever cannot be added during propagation?

Below is the code I think should reproduce the error (apologies for how long it is). I believe it comes down to a timing error. When investigating, the scheduled burn is set for a time before the next time step.

public class simpleAtmosphereDrag {

    // ----- Simulation Constants
    private static final String START_TIME = "2025-12-29T00:00:00";
    private static final double SIMULATION_TIME_S = 4.9 * 3600.;
    private static final double ALTITUDE_START_KM = 175, ALTITUDE_END_KM = 175, ALTITUDE_DELTA = 25;

    // ----- Spacecraft detail
    private final static double MASS_KG = 100.0; // kg
    private final static double CROSS_SECTION_M2 = 0.5;
    private final static double CD = 2.5; // for constant Cd model, 

    // ----- Thruster
    private final static double EFFICIENCY = 0.5;
    private final static double THRUST = 0.6; // N
    private final static double ISP = 3000.0; // s
    private final static double DALTITUDE_THRUST = 5; // How far sc falls below starting alt before thrusting (km)

    // ----- Propagator details
    private static final double MIN_STEP = 1.0;
    private static final double MAX_STEP = 100.0;
    private static final double POSITION_TOLERANCE = 10.0;

    private simpleAtmosphereDrag() {}

    public static void main(String[] args) {

        // ====== Initialize Orekit data (needs a folder with Earth orientation, ephemeris, etc.)
        initOrekit.initializeOrekitData();

        for (double ALTITUDE_KM = ALTITUDE_START_KM; ALTITUDE_KM <= ALTITUDE_END_KM; ALTITUDE_KM += ALTITUDE_DELTA){

            System.out.printf("Alt: %.0f\n", ALTITUDE_KM);

            // ====== Setup Reference Frames and Time Parameters
            Frame emeFrame = FramesFactory.getEME2000();
            Frame earthFrame = FramesFactory.getITRF(IERSConventions.IERS_2010, true); // Earth Frame
            AbsoluteDate startDate = new AbsoluteDate(START_TIME, TimeScalesFactory.getUTC());
            AbsoluteDate endDate = startDate.shiftedBy(SIMULATION_TIME_S);
            
            // ====== Generate the Earth and Sun
            BodyShape earth = new OneAxisEllipsoid(Constants.WGS84_EARTH_EQUATORIAL_RADIUS,
                                            Constants.WGS84_EARTH_FLATTENING, 
                                            earthFrame);
            PVCoordinatesProvider sun = CelestialBodyFactory.getSun();

            // ====== Arbitrary Orbit (Keplerian)
            Orbit orbit = buildOrbit(ALTITUDE_KM, startDate);
            
            // ====== Atmosphere and Drag Models
            NRLMSISE00InputParameters weatherData = new CssiSpaceWeatherData("SpaceWeather-All.*\\.txt");
            Atmosphere atmosphere = new NRLMSISE00(weatherData, sun, earth);
            IsotropicDrag spacecraft = new IsotropicDrag(CROSS_SECTION_M2, CD);
            DragForce dragModel = new DragForce(atmosphere, spacecraft);
            
            // ====== Gravity Force Model
            ForceModel gravityModel = createGravityModel(earthFrame);
        
            // ====== Attitude Provider (Nadir Locking for now)
            AttitudeProvider attitudeProvider = new LofOffset(emeFrame, LOFType.TNW);

            // ====== Thruster Model
            ThrusterModel thrusterModel = new ThrusterModel(EFFICIENCY, THRUST, ISP);

            // ====== Propagator
            NumericalPropagator propagator = createNumericalPropagator(orbit, POSITION_TOLERANCE, MIN_STEP, MAX_STEP);

            // ====== Propulsion Detectors
            double thresholdSMAm = (ALTITUDE_KM - DALTITUDE_THRUST) * 1e3 + Constants.WGS84_EARTH_EQUATORIAL_RADIUS; // m
            double targetSMAm = ALTITUDE_KM * 1e3 + Constants.WGS84_EARTH_EQUATORIAL_RADIUS; // m
            
            // Create shared guidance state
            GuidanceState guidanceState = new GuidanceState();

            // ---- SMA Detector - when SC falls below threshold
            SMAHandler smaHandler = new SMAHandler(guidanceState);
            SMADetector smaDetector = new SMADetector(smaHandler, thresholdSMAm);

            // ---- Perigee Detector - when at perigee to perform orbit raising burn
            PerigeeHandlerFull perigeeHandlerFull = new PerigeeHandlerFull(guidanceState, propagator, thrusterModel, targetSMAm);
            PositionAngleDetector perigeeDetector = new PositionAngleDetector(OrbitType.KEPLERIAN, PositionAngle.TRUE, 0.).withHandler(perigeeHandlerFull);
            
            // ====== Add everything to the Propagator
            // ---- 1. Set Initial Spacecraft State
            SpacecraftState initialState = new SpacecraftState(orbit, MASS_KG);
            propagator.setInitialState(initialState);
            propagator.setAttitudeProvider(attitudeProvider);

            // ---- 2. Add all force models
            propagator.addForceModel(dragModel);
            propagator.addForceModel(gravityModel);

            // ---- 3. Add propulsion detectors
            propagator.addEventDetector(smaDetector);
            propagator.addEventDetector(perigeeDetector);
            
            // ---- 4. Add step handlers/output
            StepHandlerMultiplexer multiplexer = new StepHandlerMultiplexer();  
            // Debugging when forces are added
            OrekitStepHandler forcesPrinter = PrintPropagatorForces(propagator);
            multiplexer.add(forcesPrinter);
            propagator.setStepHandler(multiplexer);
            
            // ====== Propagate
            SpacecraftState currentState = propagator.propagate(endDate);
            System.out.printf("Simulated to: %s", currentState.getDate());
        }
    }

    // ================================ Helper Methods ================================

    private static Orbit buildOrbit(double altitudeKm, AbsoluteDate startDate) {
        double ra = altitudeKm * 1000.0; // altitude at apogee
        double rp = ra; // altitude at perigee
        double ae = Constants.WGS84_EARTH_EQUATORIAL_RADIUS; // Earth's radius
        double a = (rp + ra + 2* ae) / 2.0; // Semi-major axis in meters
        double e = 0; // circular orbit
        double i = FastMath.toRadians(98.);
        double mu = Constants.WGS84_EARTH_MU;
        Frame emeFrame = FramesFactory.getEME2000();

        Orbit kepOrbit = new KeplerianOrbit(a, e, i, 0.0, 0.0, 0.0,  PositionAngle.TRUE, emeFrame, startDate, mu);

        return kepOrbit;
    }


    private static ForceModel createGravityModel(Frame earthFrame) {

        NormalizedSphericalHarmonicsProvider gravityField =
        GravityFieldFactory.getNormalizedProvider(0,0);
        ForceModel gravityModel = new HolmesFeatherstoneAttractionModel(earthFrame, gravityField);

        return gravityModel;
    }

    private static NumericalPropagator createNumericalPropagator(Orbit initialOrbit, double positionTolerance, double minStep, double maxStep) {

        double[][] tolerances = NumericalPropagator.tolerances(positionTolerance, initialOrbit, OrbitType.CARTESIAN);
        AdaptiveStepsizeIntegrator integrator = new DormandPrince54Integrator(
            minStep,
            maxStep,
            tolerances[0], // absolute tolerances (array)
            tolerances[1] // relative tolerances (array)
        );
        NumericalPropagator propagator = new NumericalPropagator(integrator);
        propagator.setOrbitType(OrbitType.CARTESIAN);

        return propagator;
    }

    private static OrekitStepHandler PrintPropagatorForces(
        NumericalPropagator propagator) {

        return new OrekitStepHandler() {
            
            @Override
            public void handleStep(OrekitStepInterpolator interpolator) {

                SpacecraftState s = interpolator.getCurrentState();
                
                List<ForceModel> forces = propagator.getAllForceModels();

                // Only print if more than 3 forces active
                if (forces.size() > 3) {

                    // Check all ForceModels
                    double a = s.getA();
                    double altKm = (a - Constants.WGS84_EARTH_EQUATORIAL_RADIUS) / 1e3; // km
                    System.out.printf("t=%s, SMA Altitude = %.2f km%n", s.getDate(), altKm);
                    for (ForceModel fm : propagator.getAllForceModels()) {
                        double[] params = fm.getParameters(); // Get the parameters to receive accelerations
                        Vector3D acc = fm.acceleration(s, params);

                        System.out.printf("     ForceModel active: %s, acc=%.2e m/s2%n",
                                        fm.getClass().getSimpleName(),
                                        acc.getNorm()
                                    );
                    }
                    System.out.println();
                }
            }

            @Override
            public void finish(SpacecraftState finalState) {}
        };
    }

          
}


public class GuidanceState {

    public boolean belowThreshold = false;
    public BurnPhase phase = BurnPhase.IDLE;
    
}
public class PerigeeHandlerFull implements EventHandler<PositionAngleDetector>{

    private final GuidanceState guidanceState;
    private final NumericalPropagator propagator;
    private final ThrusterModel thruster;
    private final double desiredRad;

    public PerigeeHandlerFull(GuidanceState guidanceState,
                          NumericalPropagator propagator,
                          ThrusterModel thruster,
                          double desiredRad) {

        this.guidanceState = guidanceState;
        this.propagator = propagator;
        this.thruster = thruster;
        this.desiredRad = desiredRad;
    }

    @Override 
    public Action eventOccurred(SpacecraftState s,
                                  PositionAngleDetector detector,
                                  boolean increasing) {
        if (!increasing) {
            return Action.CONTINUE;
        }

        if (guidanceState.belowThreshold && 
            guidanceState.phase == BurnPhase.WAIT_PERIGEE) {

            // Constants
            double mu = Constants.WGS84_EARTH_MU;

            // State Variables
            PVCoordinates perigeePV = s.getPVCoordinates();
            double perigeePosNorm = perigeePV.getPosition().getNorm();
            Vector3D perigeeVel = perigeePV.getVelocity();
            double perigeeVelNorm = perigeeVel.getNorm();

            // Thruster Quantities
            double perigeeMass = s.getMass();
            double thrust = thruster.getThrust();
            double isp = thruster.getISP();

            // Burn values - assuming starting from ~circular orbit
            double aTrans = (perigeePosNorm + desiredRad) / 2;
            double vTrans = FastMath.sqrt(2 * mu / perigeePosNorm - mu / aTrans);
            double dvPerigee = vTrans - perigeeVelNorm;

            // Calcualte the burn time - bad approximation, will need to do something else eventually
            AbsoluteDate startPerigeeBurn = s.getDate();
            double perigeeBurnTime = dvPerigee * perigeeMass / thrust;
            Vector3D burnDir = perigeeVel.normalize();
            
            ConstantThrustManeuver perigeeBurn = new ConstantThrustManeuver(startPerigeeBurn, perigeeBurnTime, thrust, isp, burnDir);
            propagator.addForceModel(perigeeBurn);

            guidanceState.phase = BurnPhase.WAIT_APOGEE;

            System.out.println("   --- Made it to Perigee, Scheduled Burn");
            System.out.printf("   --- ConstantThrustManuever(%s, %.2f, %.2f, %.1f, [%.2f, %.2f, %.2f])%n", startPerigeeBurn, perigeeBurnTime, thrust, isp, burnDir.getX(), burnDir.getY(), burnDir.getZ());
            System.out.println();

            guidanceState.phase = BurnPhase.WAIT_APOGEE;

        }


        return Action.CONTINUE;
   }

    
    
}
public class SMAHandler implements EventHandler<SMADetector>{

    private final GuidanceState guidanceState;

    public SMAHandler(GuidanceState guidanceState){
        this.guidanceState = guidanceState;
    }

    @Override
    public Action eventOccurred(SpacecraftState s,
                                SMADetector detector,
                                boolean increasing) {
        if (!increasing) { // crossed downward -> below threshold
            guidanceState.belowThreshold = true;
            guidanceState.phase = BurnPhase.WAIT_PERIGEE;

            // Debugging print infor
            double a = s.getA();
            double altKm = (a - Constants.WGS84_EARTH_EQUATORIAL_RADIUS) / 1e3; // km
            System.out.printf("Spacecraft at %.1f km -> Waiting for Perigee%n", altKm);
        }

        return Action.CONTINUE;
    }

    
}
public class SMADetector extends AbstractDetector<SMADetector> {

    private final double thresholdSMAm; // the threshold semi-major axis (m), altitude + equitorial radius

    public SMADetector(EventHandler<SMADetector> handler, double thresholdSMAm) {
        super(DEFAULT_MAXCHECK, DEFAULT_THRESHOLD, DEFAULT_MAX_ITER, handler);
        this.thresholdSMAm = thresholdSMAm;
    }

    public SMADetector(double maxcheck, double threshold, int max_iter, EventHandler<SMADetector> handler, double thresholdAltitudeKm) {
        super(maxcheck, threshold, max_iter, handler);
        this.thresholdSMAm = thresholdAltitudeKm;
    }

    public double getThresholdAltitudeM() {
        return thresholdSMAm;
    }

    @Override
    public double g(SpacecraftState s) {
        double aM = s.getA();
        // g positive -> negative when SMA is less than lower threshold, thrust ON
        // g negative -> positive when SMA is less than higher threshold, thrust OFF
        double g = aM - thresholdSMAm;

        return g;
    }

    @Override
    protected SMADetector create(double newMaxCheck, double newThreshold, int newMaxIter, EventHandler newHandler) {
        return new SMADetector(newMaxCheck, newThreshold, newMaxIter, newHandler, thresholdSMAm);
    }


    
}
public class ThrusterModel {

    /** Input values */
    private final double efficiency;
    private final double thrust; // Thrust (N)
    private final double isp; // Specific impulse (s) - currently for tracking fuel consumption

    /** Calculated values */
    private final double power; // Power (kW) - f(thrust, isp, efficiency)
    private final double mdot; // Exhaust mass flow rate - f(thrust, isp)
    private final double exhaustVelocity; // Thrust exhaust velocity (km/s) - f(thrust, mdot)

    public ThrusterModel(double efficiency, double thrust, double isp) {

        this.efficiency = efficiency;
        this.thrust = thrust;
        this.isp = isp;

        double g0 = Constants.G0_STANDARD_GRAVITY;

        this.power = this.thrust * g0 * this.isp / (2 * this.efficiency) / 1000.; // Feldman2022
        this.mdot = this.thrust / (this.isp * g0);
        this.exhaustVelocity = this.thrust / this.mdot / 1000.;
    }

    public double getThrust() {
        return this.thrust;
    }

    public double getMassFlowRate() {
        return this.mdot;
    }

    public double getPower() {
        return this.power;
    }

    public double getExhaustVelocity() {
        return this.exhaustVelocity;
    }

    public double getISP() {
        return this.isp;
    }
    
}

The output when I run this is:

Alt: 175
Spacecraft at 170.0 km -> Waiting for Perigee
   --- Made it to Perigee, Scheduled Burn
   --- ConstantThrustManuever(2025-12-29T04:51:50.00006656309779Z, 303.06, 0.60, 3000.0, [-0.91, 0.06, -0.42])

t=2025-12-29T04:51:51.000Z, SMA Altitude = 168.92 km
     ForceModel active: DragForce, acc=1.53e-04 m/s2
     ForceModel active: HolmesFeatherstoneAttractionModel, acc=0.00e+00 m/s2
     ForceModel active: ConstantThrustManeuver, acc=0.00e+00 m/s2
     ForceModel active: NewtonianAttraction, acc=9.30e+00 m/s2

t=2025-12-29T04:53:31.000Z, SMA Altitude = 168.89 km
     ForceModel active: DragForce, acc=1.69e-04 m/s2
     ForceModel active: HolmesFeatherstoneAttractionModel, acc=0.00e+00 m/s2
     ForceModel active: ConstantThrustManeuver, acc=0.00e+00 m/s2
     ForceModel active: NewtonianAttraction, acc=9.30e+00 m/s2

t=2025-12-29T04:54:00.000Z, SMA Altitude = 168.88 km
     ForceModel active: DragForce, acc=1.75e-04 m/s2
     ForceModel active: HolmesFeatherstoneAttractionModel, acc=0.00e+00 m/s2
     ForceModel active: ConstantThrustManeuver, acc=0.00e+00 m/s2
     ForceModel active: NewtonianAttraction, acc=9.30e+00 m/s2

Hello @sswen20

Thank you for providing a code sample. I apologize for the delay, i’m a bit busy at this moment :folded_hands:. I’ll try to look into it asap.

Cheers,
Vincent

Hello @sswen20,

Regarding your main issue of not being able to add ConstantThrustManeuver during propagation, it is indeed not possible at this moment. They are considered in the org.orekit.propagation.numerical.NumericalPropagator.Main#setUpInternalDetectors method which is in fact called before propagation (see createODE(integrator); in org.orekit.propagation.integration.AbstractIntegratedPropagator#integrateDynamics for reference).

You might be interested in the following thread which covers a station keeping case using low thrust : Station keeping using low thrust - #20 by Vincent. Full code snippets are provided in both Python and Java so it might give you some insights on ways to do what you want.

Cheers,
Vincent

@Vincent Thank you for getting back to this! That makes sense from what I was seeing.

Thank you for linking the other example! I ended up doing something similar but with a ConfigurableLowThrustManeuver. It’s helpful to see other ways of addressing it!

1 Like