Building and flying
Steering and guidance
Guidance planes, pitch and yaw, prograde and retrograde references, and the prediction tools.
Steering and guidance are two different jobs, and the simulator does one of them for you. Steering is holding the nose where it has been told to point: the built-in autopilot does that. Guidance is deciding where to point it, and that is the program's.
This page is about the second job. It covers the frame directions are given in, the two angles that name a direction in it, the reference directions a program usually steers by, and the predictors that tell a program where it is going to come down, and how far off that is.
The autopilot
fc.steer(pitch, yaw) hands a direction to an attitude controller that flies
all three axes with whatever the vehicle has: engine gimbal while the engines
burn, the RCS thrusters, and the grid fins once they are out and the air is
thick enough to matter. It also holds roll, keeping the vehicle level with the
guidance plane described below.
It is a controller, not a teleport. Engines swing at their own slew rates — 15°/s for the Merlin 1D and the Raptor 2, 10°/s for the RS-25 — and cold-gas thrusters are weak: a 180° flip of a stage with its engines off takes tens of seconds and tens of kilograms of gas. On real launch sites the controller also works the way a real one does, from a model of the vehicle's aerodynamics and the forecast wind rather than the gust it is actually in, so expect tenths of a degree of pointing error in rough air at maximum dynamic pressure.
Three other forms take over the same controller:
fc.steer({ elevation, heading })holds an absolute direction: degrees above the local horizon and a compass heading, independent of any plane.fc.steer(null)turns the autopilot off and lets the vehicle drift.fc.control({ gimbal, rcs, fins })gives the program the actuators directly — gimbal in degrees, RCS and fins from −1 to 1, each a number or a{ pitch, yaw, roll }— until the nextfc.steer().
Guidance planes
Every vehicle carries a guidance plane: a great circle on the Earth, or an
orbit's plane in space, with a direction along it called forward. Pitch and
yaw are measured against it, and so are fc.horizontalSpeed, fc.crossrange
and the along-track error of every prediction. Changing the plane changes what
those numbers mean; it moves nothing.
| Plane | What it is | Forward | For |
|---|---|---|---|
'launch' | The Earth-fixed great circle through the pad at fc.mission.azimuth. The default | Along the launch azimuth | The ascent |
'orbit' | The orbit's own plane, fixed in space | The direction of travel | Burns in orbit: pitch 0 is horizontal prograde, yaw 0 changes no plane |
{ target: 'LZ-1' } or 'SHIP' | The great circle through the point below the vehicle and the target, recomputed every step | Towards the target | Coming home |
{ azimuth } | The Earth-fixed great circle through the point below the vehicle at that bearing, fixed when set | Along that bearing | The last kilometres |
A fifth form, { body: 'moon' } or 'mars', holds the direction to another
body for the destination missions. fc.plane reads which kind is in use —
'launch', 'orbit', 'target' or 'azimuth' — and fc.azimuth gives
forward's compass bearing where the vehicle is. Each vehicle has its own plane,
and every stage starts on 'launch', separated boosters included.
On the pad the launch plane is the obvious frame, and Landing Zone 1 and the drone ship both lie on it. After staging it stops being useful for the upper stage: the orbit plane is fixed in space while the Earth turns underneath, and only in the orbit plane does pitch 0 mean "add speed without tilting the orbit". Coming home, the target plane makes forward mean towards home, so long and short keep their meaning whatever direction home is in.
Freezing the target plane
A target plane has one flaw, and it shows at the worst moment. It passes through the vehicle and pivots about the target, so as the vehicle closes on the target the plane swings, and if the vehicle passes over it, forward turns round. The reference programs freeze it: within 5 km of home they replace it with a fixed plane along the current bearing,
if (fc.plane === 'target' && fc.target.range < 5000) fc.setPlane({ azimuth: fc.target.bearing });
and the landing burn freezes it in any case. The Hop program flies a fixed plane from liftoff, because its landing zone can be a few hundred metres from the pad — 400 m at Vandenberg — and the rocket passes almost overhead.
Pitch and yaw
Pitch is the angle of the nose above the local horizon, measured in the guidance plane towards forward: 90° is straight up, 0° forward, 180° backward, and −90° straight down. It wraps to the range above −180° and up to 180°. Yaw is the angle of the nose out of the plane, positive to the right of forward, from −90° to 90°.
In the local frame at the vehicle — forward, right and up — the nose direction is
where is the pitch and the yaw. fc.steer(pitch) with one
argument is the same as fc.steer(pitch, 0): stay in the plane.
Figure · where the nose points
- COMMAND
- fc.steer(60, 15)
- ELEVATION
- 56.8 °
- HEADING
- 118.2 °
- ITS RETROGRADE
- fc.steer(-120, -15)
Reference directions
A program rarely steers to a fixed angle. It steers relative to where the vehicle is going, and there are three answers to that, because there are three things to go relative to.
| Relative to | Prograde | Retrograde | Use it |
|---|---|---|---|
| The ground | fc.prograde, fc.progradeYaw | fc.retrograde, fc.retrogradeYaw | Landing burns once slow; the gravity turn on a calm day |
| The air | fc.airPrograde, fc.airProgradeYaw | fc.airRetrograde, fc.airRetrogradeYaw | Anywhere the air is thick: ascent through max-Q, tail-first entry |
| Space | fc.orbitalPrograde, fc.orbitalProgradeYaw | fc.orbitalRetrograde, fc.orbitalRetrogradeYaw | Orbital burns and de-orbit |
Each is a pitch with a yaw companion, and the pair is the direction. Retrograde
is the same direction reversed: pitch plus 180°, wrapped, and yaw negated.
fc.steer(fc.airRetrograde, fc.airRetrogradeYaw) points the base exactly into
the relative wind, crosswind included. fc.steer(fc.airRetrograde) without the
yaw points into the part of the wind that lies in the plane, and in a crosswind
holds the vehicle at an angle of attack roughly equal to the yaw it dropped.
The yaw is not always wanted. Near the pad, where the vehicle is slow and
nearly vertical, the air's sideways motion is almost all wind, and following
fc.airProgradeYaw hands the launch azimuth to the weather:
the first flight shows what that does to an orbit. Fly
yaw 0 there and follow the air once the vehicle is fast.
Steering within a cone
In thick air the structure is judged on the dynamic pressure times the angle of attack, and fails at 250 kPa·°. So wherever the program wants the nose, the reference programs hold it inside a cone around the airflow whose half-angle shrinks as the dynamic pressure grows: on the ascent, 80 kPa·° divided by the dynamic pressure, between 1.5° and 10°. The cone has to be a cone, not a limit on pitch and a limit on yaw, because two separate limits allow √2 times as much on the diagonal:
// hold (pitch, yaw), but never more than max degrees from (p0, y0)
function steerWithin(fc, pitch, yaw, p0, y0, max) {
const dp = wrap180(pitch - p0), dy = yaw - y0, off = Math.hypot(dp, dy);
const k = off > max ? max / off : 1;
fc.steer(p0 + dp * k, y0 + dy * k);
}
// through max-Q: the wanted attitude, within the budget around the airflow
const maxAoa = clamp(80e3 / Math.max(fc.dynamicPressure, 1), 1.5, 10);
steerWithin(fc, pitch, yaw, fc.airPrograde, fc.airProgradeYaw, maxAoa);
The auto reference
A landing burn begins in thick air at a few hundred metres per second and ends
at walking pace over a pad. The right reference changes on the way: the
air-relative retrograde while the air dominates, the ground-relative retrograde
once the vehicle is slow and the pad is what matters. The predictors call this
blend 'auto' — air-relative above 11 kPa of dynamic pressure, ground-relative
below 5 kPa, and a linear blend between — and a program that wants to fly
exactly what the predictor assumed steers the same blend:
const k = clamp((fc.dynamicPressure - 5000) / 6000, 0, 1);
fc.steer(
fc.retrograde + k * wrap180(fc.airRetrograde - fc.retrograde),
fc.retrogradeYaw + k * (fc.airRetrogradeYaw - fc.retrogradeYaw),
);
Error and crossrange
Every prediction of where a vehicle will come down is reported relative to its target, and split along the guidance plane:
error, along the plane: positive means long, beyond the target in the forward direction; negative, short.crossrange, across it: positive means the predicted point is to the right of the target.miss, the total distance.
The same signs run through everything. fc.target.distance is how far ahead
the target lies along the plane, fc.target.crossrange how far right of the
plane it is, and fc.crossrange how far right of the plane the vehicle is.
fc.downrange is the exception that proves the rule: it is always measured
along the launch great circle from the pad, whatever plane is in use.
The split exists because the two parts have different controls. Pitch moves the landing point along the plane; yaw moves it across. A program nulls each with its own axis, and the rule for which way is the same whether the engine is burning or not: around a retrograde attitude, lean the nose the way you want the landing point to go. More pitch — the nose further back — pulls it short; more yaw moves it right. Under thrust that is because the engine pushes along the nose. Tail-first in the air it is because the body's drag and the fins push the stage towards the side the nose leans to.
On a target plane fc.crossrange and fc.target.crossrange are close to zero
by construction, because the plane passes through both. The sideways miss is
still there, in the prediction's crossrange, and yaw is what removes it. A
boostback that thrusts towards home and trims its sideways miss as it burns:
fc.setPlane({ target: 'LZ-1' }); // forward = towards the landing zone
const imp = fc.impact; // error + = long, crossrange + = right
const yaw = imp && imp.crossrange != null ? clamp(-imp.crossrange / 500, -15, 15) : 0;
fc.steer(10, yaw); // 10° above the horizon, towards home
A predicted point to the right needs a nose to the left, so the yaw is the crossrange's opposite: a kilometre right gives 2° left.
The predictors
Five calls tell a program about its future. None has side effects, and none is free.
fc.impact
Where the vehicle would come down with its engines off, with drag and the
forecast wind but not the gusts, which no real flight computer knows either. It
returns { error, crossrange, miss, time, latitude, longitude, speed } — the
last three at impact — or null in an orbit that lasts. It is recomputed at
most every 0.1 s of simulated time; fc.cachedImpact returns the last answer
without computing a new one.
fc.predict({ dv, radialDv, normalDv, pitch, yaw, target })
"Where would I come down if I burned this now?" The burn is impulsive: dv is
along the inertial velocity, positive prograde, or along pitch and yaw if
they are given; radialDv adds a push straight up; normalDv a push along the
orbit's normal, positive towards its angular momentum, which is to the left of
the direction of motion and north when flying east. target measures the answer
from the other platform. The result has the shape of fc.impact.
Its real use is measuring sensitivities. Predict a burn and a slightly different one, and the difference says how many metres of landing point one metre per second buys, which is what every closed-loop burn needs to know. The reference program's de-orbit burn has two jobs — slow down, and tilt the orbit so the ground track runs over home — and solves for both at once with Newton's method, measuring its 2 × 2 sensitivities this way:
// Which impulse — a m/s retrograde, n m/s along the orbit normal — lands on the target?
function solveDeorbit(fc, a, n) {
for (let k = 0; k < 3; k++) {
const p = fc.predict({ dv: -a, normalDv: n });
if (!p || p.error == null) return null;
if (Math.abs(p.error) < 100 && Math.abs(p.crossrange) < 100) break;
const pa = fc.predict({ dv: -(a + 0.5), normalDv: n });
const pn = fc.predict({ dv: -a, normalDv: n + 5 });
if (!pa || !pn) return null;
const ea = (pa.error - p.error) / 0.5, xa = (pa.crossrange - p.crossrange) / 0.5; // per m/s of a
const en = (pn.error - p.error) / 5, xn = (pn.crossrange - p.crossrange) / 5; // per m/s of n
const det = ea * xn - en * xa;
if (!det) return null;
// steps limited to what the sensitivities can be trusted for: the problem is far from linear
a -= clamp((p.error * xn - p.crossrange * en) / det, -20, 20);
n -= clamp((ea * p.crossrange - xa * p.error) / det, -100, 100);
}
return { a, n };
}
The answer is the velocity still to be gained. The burn is flown by steering along it, solving again every tenth of a second as the engine delivers it, and cutting when what is left is what the engine's 0.25 s tail-off will still give.
fc.predict({ landingBurn, entryBurn })
The same call with a landing burn asks a different question: coasting with the
engines off, when is the latest moment to light this burn and still stop at
the surface? landingBurn takes { throttle, engines, attitude, altitude, legs }, where altitude is how far above the surface to stop. The answer is a
stop point, described below, plus ignitionTime and ignitionAltitude, and
crashed: true as a best effort when no ignition is early enough.
Add entryBurn: { altitude, engines, throttle, untilSpeed } — by default lit
descending through 55 km on three engines at full throttle, cut at 900 m/s —
and the prediction includes an air-retrograde entry burn first, reported as
entryBurn. That is a whole booster return in one call. On real launch sites
the ignition it plans is the command, with the engine's dead time already
allowed for. It costs about 1.3 ms.
fc.stopPoint({ throttle, engines, attitude, legs })
The landing burn lit now, or the one already running, held as it is: where does
the descent stop? attitude is 'retrograde' (the default, ground-relative),
'air', 'auto', or a pitch in degrees. The stop point is
{ error, crossrange, miss, time, latitude, longitude, altitude, downrange, horizontalSpeed, crashed, impactSpeed, burnTime, propellantLeft }, where
altitude is the height of the vehicle's base above the surface when its
vertical speed reaches zero. In calm air it matches the flown burn to within
about 6 m in height and 10 m along the track. It costs about 0.7 ms.
This is the call that closes the loop during a landing burn. The reference programs bisect the throttle for the one that stops a few metres up, ten times a second, and lean the thrust off the reference to walk the stop point onto the pad:
// the throttle whose stop point is 12 m above the surface
let lo = fc.minThrottle, hi = fc.maxThrottle;
for (let k = 0; k < 6; k++) {
const u = (lo + hi) / 2, s = fc.stopPoint({ throttle: u, attitude: 'auto' });
if (s && !s.crashed && s.altitude > 12) hi = u; else lo = u;
}
fc.throttle((lo + hi) / 2);
Coming back down is about why that loop has to exist.
fc.passes({ target, hours, within })
Off the equator, an orbit's ground track passes near home only on some revolutions. Each orbit at 200 km takes about 88 minutes, the Earth turns 22° underneath in that time, and the track shifts west by that much. Launched due east from latitude φ, the pad sits at the northern tip of the track, and one revolution later the track passes a couple of hundred kilometres south of it. From Vandenberg, in a polar orbit, the pad's latitude is crossed about 2,000 km further west every revolution; in the reference flights from there, the first pass close enough to use comes about thirteen hours after launch.
fc.passes() looks ahead — 30 hours by default, within 50 km of the target —
and lists the passes: { time, distance, crossrange, latitude, longitude },
with time in seconds from now and crossrange positive when the target lies
to the right of the track. It coasts the orbit with the oblateness term
and no drag, over hours, so call it once and keep the answer. The reference
program then sleeps until 40 minutes before the pass it chose.
Which pass to take is a question of budget. Moving the ground track sideways costs normal Δv of about for a crossrange , where is the orbital speed and the orbit's radius: one radian of plane change per orbit radius. At 7.8 km/s and 200 km up, 100 km of crossrange is about 120 m/s. The reference program takes the first pass it can afford, spending at most 450 m/s — about 380 km — sideways.
fc.burnTime() and fc.timeToAltitude()
fc.burnTime(dv, throttle, engines) is how long a burn of dv takes with the
engines chosen, at the current pressure, or Infinity if the stage does not
have it. fc.timeToAltitude(h, dir) is when a vacuum coast next reaches a
height going 'up' or 'down', or null if it never does. Both are cheap.
Predictions and the truth
A prediction is only as good as the state it starts from. With the default ideal sensors that state is the truth. With realistic sensors it is the flight computer's estimate, and a few centimetres per second of velocity error becomes a kilometre of landing point after a de-orbit burn. The errors wander, so an average over a coast is much better than any single prediction. The reference program averages its predicted impact for several minutes before trimming the difference with the cold-gas thrusters, and flies the whole descent on smoothed predictions.
The predictors also cannot know what they are not told. They use the forecast wind, not the gust; the nominal thrust of the engines, not each engine's own — on real sites engines differ by about 0.75 %. A burn planned once and flown blind drifts from its plan by exactly those amounts, which is why the reference programs fly every burn that aims at a point closed-loop, on predictions refreshed as it goes.