---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
kernelspec:
  display_name: Python 3
  language: python
  name: archimedes
---

# Flight Dynamics

After [Part 1](f16_01.md), we now have a validated implementation of the Stevens-Lewis-Johnson F-16 model that's compatible with Archimedes for ODE solving, autodiff, trajectory optimization etc.

In this part we'll take a hierarchical approach, defining the flight dynamics model with nested subsystems - something like a Python version of Reference and Variant Subsystems in Simulink.
For much more on the "why" and "how" of this, see the tutorials on [Structured Data Types](../../trees.md) and [Hierarchical Modeling](../hierarchical/hierarchical00.md).
This structure can be reused for many conventional fixed-wing aircraft, and with light modifications can be adapted to a wide variety of other vehicle dynamics models as well.

```{image} _static/f16_subsystems_light.png
:class: only-light
```

```{image} _static/f16_subsystems_dark.png
:class: only-dark
```

## Subsystem Models

So, what's in an F-16?  We'll consider a number of "subsystems":

```
SubsonicF16
├── RigidBody
├── GravityModel
├── AtmosphereModel
├── F16Engine
├── F16Aero
└── Actuator (x3)
```

Sensors are obviously important to model as well, but we'll just be looking at basic vehicle stability and response, so we won't include these for now.

Following the hierarchical dynamics modeling design pattern, we will first create an abstract base class or `Protocol` for each of these subsystems that clearly defines their inputs and outputs.
Then we can add dynamic state to each concrete implementation as necessary.

We'll build up the interface definitions first, then see a couple of concrete implementations, and finally see how it fits together for the full vehicle model.
We won't go through all the subsystems in detail - just enough to get the general idea.
As always, the full source code is [on GitHub](https://github.com/PineTreeLabs/archimedes/tree/main/docs/source/tutorials/f16).

```{code-cell} python
:tags: [hide-cell]
from __future__ import annotations

from pprint import pprint

import matplotlib.pyplot as plt
import numpy as np

import archimedes as arc
```

```{code-cell} python
:tags: [remove-cell]
from archimedes.docs.utils import display_text, extract_py_class
```

```{code-cell} python
:tags: [remove-cell]
from pathlib import Path

plot_dir = Path.cwd() / "_plots"
plot_dir.mkdir(exist_ok=True)
```

At a high level, each of these subsystem models will adhere to one of two common interfaces (following the ideas in the [Hierarchical Design Patterns](../hierarchical/hierarchical00.md) tutorial series).

For simple "callable" components that will never be stateful (gravity and atmosphere), the subsystem will look like the following:

```python
@struct
class CallableSubsystem:
    parameter: float

    def __call__(self, *args, **kwargs):
        """Evaluate the component model (stateless algebraic function)"""
```

Subsystems that act like state-space models will have a more complex interface that clearly defines structured input, output, and dynamic state types:

```python
@struct
class DynamicSubsystem:
    parameter: float
    
    @struct
    class Input:
        ...  # Define fields of the subsystem inputs

    @struct
    class Output:
        ...  # Define fields of the subsystem outputs

    @struct
    class State:
        ...  # Define dynamic state fields, or "pass" for empty state

    def dynamics(self, t: float, x: State, u: Input) -> State:
        ... # Calculate time derivatives, or "return x" if component model has no state

    def output(self, t: float, x: State, u: Input) -> Output:
        ... # Calculate subsystem outputs
```

This pattern lets us predictably and safely pass data between components and define different model variants of a particular subsystem.
Providing placeholders for state and dynamics also makes it easy to switch between idealized models (pure feedthrough) and higher-fidelity models with internal dynamics.

Let's start simple: gravity.

### Gravity

Here we just need to compute the gravitational acceleration vector as a function of position.
There's nothing complicated about this, and it doesn't require a state, so we can use a simple callable `Protocol` to define the interface:

```python
class GravityModel(Protocol):
    def __call__(self, p_E: np.ndarray) -> np.ndarray:
        """Gravitational acceleration at the body CM in the earth frame E

        Args:
            pos: position vector in the earth frame E [ft]

        Returns:
            g_E: gravitational acceleration in earth frame E [ft/s^2]
        """
```

This already gives us a lot of flexibility - we could model a spherical potential, oblate Earth, perturbations, etc.
It's not _totally_ general, but for the purposes of F-16 flight we're pretty well covered.

In fact, our main gravity model implementation will be even simpler:

```python
@struct
class ConstantGravity:
    """Constant gravitational acceleration model

    This model assumes a constant gravitational acceleration vector
    in the +z direction (e.g. for a NED frame with "flat Earth" approximation)
    """

    g0: float = GRAV_FTS2  # ft/s^2

    def __call__(self, p_E):
        return np.hstack([0, 0, self.g0])
```

:::{note}
A reminder about `Protocol` types in modern Python - these are a way to define the behavior of classes.
They're not directly inherited from, but because we've written `ConstantGravity` to implement the `GravityModel` interface, type hints and static type checking will work if we say some argument will be a `GravityModel` and then pass it `ConstantGravity`.  More importantly for our purposes, this helps create well-defined interfaces between subsystems.

For "meatier" subsystems where we actually want inheritance behavior, abstract base classes are often a better choice to define the interface. 
:::

### Atmosphere

Atmosphere we can handle similarly.
For flight dynamics we mainly want an atmosphere model that will compute Mach number and dynamic pressure as a function of "true" (wind-relative) airspeed and altitude.
This is easily encapsulated in another `Protocol`:

```{code-cell} python
:tags: [remove-input]
extract_py_class("atmosphere.py", "AtmosphereModel")
```

The atmosphere model used in the reference implementation assumes an ideal gas in hydrostatic equilibrium with a linear temperature gradient.
We'll call it `LinearAtmosphere` for short:

```{code-cell} python
:tags: [remove-input]
extract_py_class("atmosphere.py", "LinearAtmosphere")
```

For completeness, the [source code](https://github.com/PineTreeLabs/archimedes/tree/main/docs/source/tutorials/f16/atmosphere.py) also implements the US Standard Atmosphere 1976 model.
The two are nearly identical in a particular altitude band, but the USSA1976 model is valid over a much wider altitude range.

### Actuators

The control surface actuator components model the electrical-mechanical-hydraulic system that produces deflections in the control surfaces.
We'll consider these to be single-input, single-output (SISO) systems mapping from commanded position $u$ to actual position $y$ (in this case, both measured in degrees).

Here we will depart from the `Protocol` approach and define a base class, since there is some default behavior that we'd like the classes to inherit.
The base class is stateless, and requires at a minimum that children implement the `output` method:

```{code-cell} python
:tags: [remove-input]
extract_py_class("actuator.py", "Actuator")
```

The simplest actuator model is direct linear feedthrough: $y = u$.
That is, the ideal model assumes that the commanded position is realized instantaneously.
We can implement this with a gain for a little more flexibility, though in this formulation the gain will always be 1:


```{code-cell} python
:tags: [remove-input]
extract_py_class("actuator.py", "IdealActuator")
```

As with the base class, this model is "stateless".
More precisely, it has an empty state (`Actuator.State`) that we'll pass around as a placeholder for compatibility with stateful models.

In reality, of course, the control surfaces can't respond instantaneously.
A common way to model the real response is with a rate- and position-limited first-order lag.
This can be a bit tricky to implement as a smooth ODE; technically we should be defining zero-crossing events to handle position and velocity saturation.
However, CVODES can handle $C^0$-smooth right-hand side functions fairly robustly, so the following model works:

```{code-cell} python
:tags: [remove-input]
extract_py_class("actuator.py", "LagActuator")
```

The rate limits can add significant effective lag into the system for aggressive changes in deflection.
For instance, here is the step response of the F-16 elevator using the parameters in Appendix A.4 of Stevens, Lewis, and Johnson:

```{code-cell} python
:tags: [remove-cell]
from actuator import LagActuator
```

```{code-cell} python
:tags: [hide-cell, remove-output]
tau = 0.0495  # Time constant [sec]
rate_limit = 60.0  # Maximum rate [deg/sec]
pos_limits = 25.0  # Maximum deflection [deg]

lag_actuator = LagActuator(tau=tau)
lim_actuator = LagActuator(
    tau=tau,
    rate_limit=rate_limit,
    pos_limits=(-pos_limits, pos_limits),
)


# Smoothed step input
def u(t):
    return pos_limits * 0.5 * (1 - np.tanh(100 * (t - 1.0)))


def ode_rhs(t, x, actuator):
    x = LagActuator.State(x)
    x_t = actuator.dynamics(t, x, u(t))
    return x_t.position


t0, tf = 0.0, 2.0
ts = np.linspace(t0, tf, 501)
us = u(ts)
x0 = 0.0  # Initial deflection angle [deg]
xs_lim = arc.odeint(ode_rhs, (t0, tf), x0, t_eval=ts, args=(lim_actuator,))
xs_lag = arc.odeint(ode_rhs, (t0, tf), x0, t_eval=ts, args=(lag_actuator,))

plt.figure(figsize=(7, 2))
plt.plot(ts, us, label="Command")
plt.plot(ts, xs_lag, label="Unconstrained")
plt.plot(ts, xs_lim, label="Rate Limited")
plt.xlabel("Time [sec]")
plt.ylabel("Elevator [deg]")
plt.legend()
plt.grid()
```

```{code-cell} python
:tags: [remove-cell]

for theme in {"light", "dark"}:
    arc.theme.set_theme(theme)

    plt.figure(figsize=(7, 2))
    plt.plot(ts, us, label="Command")
    plt.plot(ts, xs_lag, label="Unconstrained")
    plt.plot(ts, xs_lim, label="Rate Limited")
    plt.xlabel("Time [sec]")
    plt.ylabel("Elevator [deg]")
    plt.legend()
    plt.grid()

    plt.savefig(plot_dir / f"f16_02_0_{theme}.png")
    plt.close()
```

```{image} _plots/f16_02_0_light.png
:class: only-light
```

```{image} _plots/f16_02_0_dark.png
:class: only-dark
```

The output of these actuator models will be fed to the aerodynamics model as the actual control surface deflections.

Incidentally, this is a pretty good illustration of why it's important to have a healthy phase margin - the rate limiting is a nonlinear effect that would be missed by a stability analysis, leading to a significant underestimate of lag in the actuator subsystem.

### Rigid Body Dynamics

The next "subsystem" is an odd one.
[`RigidBody`](#archimedes.spatial.RigidBody) is fully generic, meaning that unlike the aircraft-specific aerodynamics and engine models, or even the Earth-specific gravity model, `RigidBody` doesn't really know or care anything about the body that it's simulating.
In fact, we don't even need to actually instantiate it - the pieces we need are all class attributes or methods.
`RigidBody.dynamics` simply takes forces, moments, and inertia characteristics and calculates the time derivatives of the position, attitude, velocity, and angular velocity.

Because it is generic and applicable across engineering domains, this class is built into the [`spatial`](#archimedes.spatial) module of Archimedes.
For more on `RigidBody` see the [blog post](../../blog/2025/spatial.md) introducing the [`spatial`](#archimedes.spatial) module.

For now, all we really need to know is how the `State` and `dynamics` of the `RigidBody` subsystem are defined:

```python
class RigidBody:
    """6-dof rigid body dynamics model"""

    @struct
    class State:
        pos: np.ndarray  # Position of the center of mass in the world frame
        att: Attitude  # Attitude (orientation) of the vehicle
        vel: np.ndarray  # Velocity of the center of mass in body frame B
        w_B: np.ndarray  # Angular velocity in body frame (ω_B)

    @classmethod
    def dynamics(cls, t: float, x: State, u: Input) -> State:
        """Calculate 6-dof dynamics"""

```

This will become an integral part of the final vehicle model.

### Engine and Aerodynamics

The final two subsystems are the most unique to the F-16 - and also the most complex.
When building custom flight dynamics models, usually the bulk of the work goes into getting these subsystems right.

The details of these models are therefore not likely to be of broad interest (though the source code is [on GitHub](https://github.com/PineTreeLabs/archimedes/tree/main/docs/source/tutorials/f16/)).
However, we can understand the role that each of them play by defining their interface as a `Protocol`:

```python
class F16Engine(Protocol):
    """F-16 engine model"""

    @struct
    class Input:
        throttle: float  # Throttle position [0-1]
        alt: float  # Altitude [ft]
        mach: float  # Mach number

    @struct
    class Output:
        thrust: np.ndarray  # Thrust magnitude [lbf]

    @struct
    class State:
        ...

    def dynamics(self, t: float, x: State, u: Input) -> State:
        """Time derivative of engine model state"""

    def output(self, t: float, x: State, u: Input) -> Output:
        """Calculate engine thrust output"""

    def trim(self, throttle: float) -> State:
        """Calculate trim conditions for the engine model"""


class F16Aero(Protocol):
    @struct
    class Input:
        condition: FlightCondition  # Flight condition
        w_B: np.ndarray  # Angular velocity in body frame (ω_B) [rad/s]
        elevator: float  # Elevator deflection [deg]
        aileron: float  # Aileron deflection [deg]
        rudder: float  # Rudder deflection [deg]
        xcg: float  # Longitudinal center of gravity [% of cbar]

    @struct
    class Output:
        CF_B: np.ndarray  # Aerodynamic force coefficients in body frame
        CM_B: np.ndarray  # Aerodynamic moment coefficients in body frame

    @struct
    class State:
        ...  # Unsteady aerodynamic states

    def dynamics(
        self, t: float, x: State, u: Input, vehicle: F16Geometry
    ) -> State:
        """Time derivatives of unsteady aero states"""
    
    def output(
        self, t: float, x: State, u: Input, vehicle: F16Geometry
    ) -> Output:
        """Compute aerodynamic force and moment coefficients"""

    def trim(self) -> State:
        """Return a steady aerodynamic state"""
```

This is hopefully fairly self-explanatory; both have clearly defined input and output types, a flexible container for dynamic states, and methods to calculate derivatives, output, and steady state.

The `FlightCondition` type used as part of the inputs to the aerodynamic model is essentially a cache for "derived quantities" computed once but reused across subsystems - angle of attack, mach number, dynamic pressure, etc.
We'll see how this is defined and how it works when we integrate the subsystems into the full vehicle model.

## Putting It All Together

We've now defined interfaces for all of our major subsystems and are ready to put it all together.
Here's what the full model looks like:

```{code-cell} python
:tags: [remove-input, hide-cell]
extract_py_class("f16.py", "FlightCondition")
extract_py_class("f16.py", "F16Geometry")
extract_py_class("f16.py", "SubsonicF16")
```

There is a lot of bookkeeping, but the dependencies are clear and the subsystems are modular and well-defined.
The key method is `net_forces`, which goes through each of the subsystems, computes their outputs, and assembles these into force and moment vectors.
The `dynamics` method has a similar structure, but serves to go through each subsystem and compute its time derivatives.

By construction, subsystems that don't have a state will have an empty `State` type, and their `dynamics` method acts like a placeholder.
But having this in place means that its easy to change the subsystem model and add internal dynamics without modifying the top-level system code at all.

:::{note}
At first glance it may look like there's a lot of overhead in this implementation as data gets passed to various containers and shuffled around between methods and function calls.
But keep in mind that all of this happens at "compile time" in Python - once the symbolic computational graph is constructed there's zero overhead from any of these wrappers, containers, and Python function calls.
:::

### Handling `FlightConditions`

One pattern that's worth pointing out here is the use of the auxiliary state-like structure `FlightConditions`.
This is essentially a cache for data that is somewhat expensive to compute (requiring trigonometric functions, lookup table evaluations, etc.) and is reused across multiple subsystems.
The pattern is to put all of this in a single struct and calculate all the "derived quantities" at the beginning of the `dynamics` function.

One benefit of this approach is that it lets you _optionally_ pass the flight condition to other methods like `net_forces` as a keyword arg.
When `net_forces` is called from `dynamics`, the flight conditions are passed and don't need to be recomputed.
But if you're calling `net_forces` directly as part of some external analysis, you don't need to manually compute the flight conditions - `if z is None` handles this smoothly.

Keep this pattern in mind - it comes up more often than you might think when working with hierarchical or component-based modeling.

### Was It All Worth It?

So... remember that 150-line implementation of `f16_dynamics` ported from Fortran in [Part 1](f16_01.md)?
Now we've done a whole lot of work and ended up with a 200-line implementation that doesn't even do any of the actual calculations!

But if you take a step back, we haven't just built a _model_, we've written something more like a _framework_.
It's more work up front, but you end up with reusable component models, a scalable architecture, and a flexible, configurable dynamics model.
In the long run, this helps produce a more maintainable and self-documenting code base and can shave time off of future development cycles.

On the other hand, if you think all this is overly complicated and unnecessary for your use case - you may be right.
Sometimes a few modular functions and a single ODE model are all that's needed.

In any case, our F-16 model is now complete.
In [Part 3](f16_03.md) we'll see how to use this to find "trimmed" operating points.

## [Advanced]: Configuration Management

Our flight dynamics model has 9 subsystems, plus a few extra parameters.
The struct definition without any methods looks like this:

```python
@struct
class SubsonicF16:
    gravity: GravityModel
    atmos: AtmosphereModel
    engine: F16Engine
    aero: F16Aero
    geometry: F16Geometry
    elevator: Actuator
    aileron: Actuator
    rudder: Actuator

    m: float  # Vehicle mass [slug]
    J_B: np.ndarray  # Vehicle inertia matrix [slug·ft²]
    xcg: float = 0.35  # CG location (% of cbar)
    hx: float = 160.0  # Engine angular momentum
```

Some of these, like the gravity and aerodynamics models, take little to no configuration and will almost always have the defaults.
For others, like the actuators, we might want to routinely swap between ideal and lagged models or add and remove rate-limiting, depending on the particular analysis.

This can of course be done from code.
For instance, to create a simplified model for stability analysis and a full model for nonlinear simulation we could do the following:

```python
stability_model = SubsonicF16(
    engine=TabulatedEngine(),
    elevator=IdealActuator(),
    aileron=IdealActuator(),
    rudder=IdealActuator(),
    xcg=0.3,
)

full_model = SubsonicF16(
    engine=NASAEngine(),
    elevator=LagActuator(
        tau=0.0495,
        rate_limit=60.0,
        pos_limits=(-25.0, 25.0),
    )
    aileron=LagActuator(
        tau=0.0495,
        rate_limit=80.0,
        pos_limits=(-21.5, 21.5),
    )
    rudder=LagActuator(
        tau=0.0495,
        rate_limit=120.0,
        pos_limits=(-30.0, 30.0),
    )
)
```

This is perfectly fine, but becomes cumbersome as the complexity and configurability of the models grows.

Archimedes also provides capabilities for defining configuration management systems that work well with the `struct`-based hierarchical modeling.
We won't go into depth about how to set this up here - see the [Hierarchical Modeling](../hierarchical/hierarchical02.md) tutorial for details - but this allows you to define the configuration in a YAML or JSON file.

For instance, the same configurations as above can be created with the following YAML config:

```{code-cell} python
:tags: [remove-input]
with open("config.yaml", "r") as f:
    config_text = f.read()
    display_text(config_text, language="yaml")
```

This can be loaded and converted to models with:

```{code-cell} python
:tags: [hide-output]
from f16 import SubsonicF16

stability_model = SubsonicF16.from_yaml("config.yaml", "stability")
pprint(stability_model)
```

```{code-cell} python
:tags: [hide-output]
full_model = SubsonicF16.from_yaml("config.yaml", "full")
pprint(full_model)
```

This lets you set up several pre-baked vehicle configurations for different purposes that can be version-controlled and reused across different analysis codes.

```{image} ../hierarchical/_static/config_lifecycle_light.png
:class: only-light
```

```{image} ../hierarchical/_static/config_lifecycle_dark.png
:class: only-dark
```

However, as with the hierarchical design itself, for your own use cases you may find the configuration management system to be either helpful and scalable, or complicated overkill - in either case you are probably correct.

## Conclusion

We now have a modular, hierarchical model of F-16 flight dynamics, with components for gravity, atmosphere, aerodynamics, propulsion, and control surface actuators.
We can construct the model either directly in Python, or using the configuration management system.

In the end we have `SubsonicF16.dynamics(t, x, u)`, which computes the time derivatives given state and input data.
In other words, we have gone the long way around to the same destination as the ported Fortran code in Part 1 - a nonlinear state-space model of the form:

```{math}
\dot{x} = f(t, x, u)
```

In [Part 3](f16_03.md) we will continue on with trim and stability analysis of this model.