FlapSim aerodynamics (aero): implementation, derivation and validation¶
Translated from the Chinese original (docs/zh/derivations/quasi_steady_aero.md). It documents the implementation as it stood before 2026-10-28; the pitching-moment sign and the removed force flip are described in quasi_steady_model.md.
This document keeps the complete code-level implementation and validation details. The simplified ideal-hover derivation intended for the main text of the paper is in hover_simplification.md, where \(a_{U1}=a_{U0}=0\) is taken under explicit assumptions.
This document collects the actual implementation in flapsim/fluid at the time of writing (the per-wing engine now lives in flapsim/aero/). Here "aero" is not one model but two models with different purposes:
fix=True:MuJoCoAeroAdaptercallsaero.pyand computes quasi-steady aerodynamic forces wing by wing at every simulation step;fix=False:AveragedHoverAerouses the cycle-averaged force and moment identified from a fixed-base frequency sweep.
One of the two is selected in SimCore.__init__; they never run at the same time. Below we first derive the per-wing model in full, then describe the cycle-averaged model and its identification relationships.
1. Files and the runtime call chain¶
The key files are:
| File | Role |
|---|---|
flapsim/fluid/aero.py |
Pure aerodynamic computation decoupled from MuJoCo: velocities, angle of attack, normal force, centre of pressure and wrench |
flapsim/fluid/mujoco_adapter.py (now a thin compatibility wrapper around flapsim/aero/base.py, AeroEngine, and the aero/quasi_steady plugin in flapsim/aero/stack.py) |
Reads state from MjData, handles the left/right wing frames, writes the wrench into xfrc_applied |
flapsim/fluid/averaged_hover.py (now flapsim/aero/cycle_averaged.py) |
Cycle-averaged hover model used in free flight |
flapsim/config/Wing.json |
Wing geometry integrals, air density and shoulder geometry parameters |
flapsim/config/AeroOptions.py |
Runtime parameters: scaling, clipping, stroke-plane angle and deviation angle |
flapsim/sim_core.py |
Selects the aero model according to fix and attaches it to the environment |
flapsim/environment/environment.py (now flapsim/core/simulation.py, class Simulation) |
Each step: update the controller first, then apply the aerodynamic forces, finally call mujoco.mj_step |
The order of operations in each fixed-base simulation step is
TrajectoryController (now flapsim/actuators/sinusoidal_stroke.py) prescribes the stroke angle as
where \(d\in\{-1,+1\}\) is the left/right wing direction and the default amplitude is \(A=1.5\ \mathrm{rad}\).
2. Coordinates, state and notation¶
2.1 Body state¶
Let \(R_{WB}\) be the rotation matrix from body coordinates to world coordinates. The world-frame velocities given by MuJoCo are first transformed into the body frame:
The implementation clips \(\lVert\boldsymbol\omega_B\rVert\) to angular_velocity_limit, default \(5000\ \mathrm{rad/s}\). The linear velocity is not clipped in the same way.
2.2 Wing motion variables¶
- \(s=+1\): left wing; \(s=-1\): right wing;
- \(\psi,\dot\psi\): stroke angle and stroke rate;
- \(\theta,\dot\theta\): feathering / wing pitch angle and its rate;
- \(\phi,\dot\phi\): deviation angle, named
deviation_angle/ratein the code; - \(\Phi,\dot\Phi\): stroke-plane angle, named
stroke_plane_angle/ratein the code; - \(d_0\): half shoulder width; \(d_s\): offset of the stroke plane relative to the body;
- \(r_w\in[0,R]\): spanwise coordinate. To avoid confusion with the body yaw rate \(r\), this document writes the spanwise coordinate as \(r_w\).
The wing aerodynamic frame convention is: +X is the wing normal, +Y is the spanwise/feathering axis. The local centre of pressure is written as
mujoco_adapter.py uses a fixed matrix to align the URDF wing-body frame with this aerodynamic frame, and mirrors it for the right wing.
3. Why the wing geometry parameters are these integrals¶
Let the actual local chord be \(c(r_w)\) and the mean chord be
Introduce the dimensionless variables
r00 to r33 are in fact chord-weighted radial moments:
z_cp0 to z_cp2 are moments weighted by the chord squared:
Why does the normal force use \(r_{kk}\) while the aerodynamic pitching moment uses \(z_{cp,k}\)? Because the strip area is \(c(r_w)dr_w\), and the distance from the centre of pressure to the leading edge is also proportional to \(c(r_w)\). Hence
The current wing is a \(70\times20\ \mathrm{mm}\) rectangular wing, so \(\hat c=1\) and therefore
These are exactly the values in Wing.json. The other main parameters are
examples/notebooks/02_wing_parameters.ipynb generates these parameters from polygon cross-sections of the wing planform and Gauss–Legendre quadrature. Its pressure-moment formula also requires the feathering-axis/leading-edge definition to be consistent with the parameter integrals; if the planform has a non-negligible chordwise offset relative to that axis, the full pressure moment acquires an offset term that the code does not currently retain.
4. Construction of the local relative velocity¶
4.1 The key structural fact: velocity is an affine function of the spanwise coordinate¶
The velocity of a point on a rigid body satisfies
The spanwise position \(\boldsymbol r(r_w)\) is linear in \(r_w\), so after projection onto the two aerodynamic directions of the wing it remains a first-order function. The code writes them as
Here \(U_i\) is the main in-plane inflow component and \(U_o\) is the out-of-plane / normal inflow component. All body translation, body rotation, stroke motion, deviation motion and geometric offsets are ultimately collected into these four coefficients.
Writing \(S_x=\sin x,C_x=\cos x\), the complete expressions in the current code are
With the defaults \(\phi=\Phi=\dot\phi=\dot\Phi=0\) they reduce to
If the body is completely stationary, one further obtains
which gives the standard result that the flapping velocity increases linearly with spanwise radius.
4.2 Why a fixed spanwise representative point is used¶
The code defines
The proof is as follows: for pure rotational flapping \(U^2\propto r_w^2\), and the force distribution satisfies
So the spanwise centre of pressure for this case is
For a rectangular wing \(r_{cp}=3R/4=0.0525\ \mathrm m\). The code uses this fixed representative point to compute the angle of attack in all operating conditions. When \(U_{i0},U_{o0}\) are large the true centre of the resultant force generally moves; the current implementation does not update the spanwise CoP dynamically, which is one of the quasi-steady reduced-order assumptions.
5. Angle of attack, normal-force coefficient and centre-of-pressure fits¶
At the fixed representative point compute
The flow-correction angle and the angle of attack are
When \(|U_i^*|\le10^{-12}\) the code sets \(\delta_\alpha=0\) directly instead of calling atan2. Near stroke reversal this angle definition therefore contains an artificial branch.
The normal-force coefficient is an empirical fit:
The chordwise centre of pressure as a fraction of the local chord is
These two expressions are empirical relations supplied as model inputs; they cannot be derived from first principles using the kinematics in the code. What can be verified rigorously are their structural properties:
That is, reversing the angle of attack reverses the normal-force coefficient, while the centre-of-pressure fraction stays the same.
6. Closed-form normal force from the strip integral¶
The local velocity squared is
Substituting the two first-order functions and expanding:
where
The quasi-steady strip normal force is
The model uses the angle of attack at the representative point, i.e. it assumes the whole wing shares one \(C_N\) at any instant. Therefore
This is force_raw. It is then multiplied by lift_scale and clipped to
where the Python default is \(F_{max}=5\ \mathrm N\).
For a stationary body and pure flapping \(U=|\dot\psi|r_w\), so
At the same stroke phase and amplitude \(\dot\psi\propto f\), hence
This is also the theoretical basis for the cycle-averaged model's use of \(L=k_f f^2\) later on; its coefficient still has to be identified with the full model, because passive feathering makes \(C_N\) vary over the cycle.
7. Aerodynamic pitching moment, chordwise CoP and rotational damping¶
7.1 Aerodynamic pitching moment produced by the pressure¶
The local pressure acts at a distance of approximately \(d_{cp}c(r_w)\) from the reference edge. The strip moment is therefore
Integrating gives
The code then multiplies by moment_scale. Note that M_aero is not written into MuJoCo independently; it is converted into an equivalent chordwise centre of pressure:
As long as the force clip is not triggered,
so the \(y\)-axis moment produced in local coordinates by the force at the centre of pressure equals M_aero exactly. If \(F_N^{raw}=0\) the code sets \(c_{cp}=0\).
7.2 Feathering rotational damping¶
The implementation uses
It is then multiplied by moment_scale and clipped to \(\pm0.05\ \mathrm{N\,m}\). Its dissipativity can be shown directly:
Hence this term never actively injects energy into the feathering degree of freedom. For the current rectangular wing \(z_{rd}=1\).
7.3 From \(M_{aero}\), the CoP and \(M_{rd}\) to the complete single-wing wrench¶
This is the part where the quantities are most easily confused. First distinguish what each of them changes:
| Quantity | Changes the magnitude of the resultant force? | Changes the point of application? | Adds an independent moment? |
|---|---|---|---|
| \(F_N\) | yes | no | no |
| \(M_{aero}\) | no | yes, through \(c_{cp}\) | not applied directly |
| \(r_{cp}\), \(c_{cp}\) | no | yes | indirectly, through the lever arm |
| \(M_{rd}\) | no | no | yes |
Therefore neither \(M_{aero}\) nor \(M_{rd}\) generates an additional share of lift. A single wing always has exactly one local normal force
The role of \(M_{aero}\) is to decide where along the chord this force is applied; \(M_{rd}\) adds a pure moment about the feathering axis. The computation can be written as
For wing \(k\), \(k\in\{L,R\}\), let \(s_L=+1\) and \(s_R=-1\). The position of the centre of pressure relative to the wing-body frame origin is
But external torques in MuJoCo must be taken about the centre of mass of that wing. Define the position of the wing centre of mass relative to the wing-body origin
then the true lever arm from the centre of mass to the centre of pressure is
The cross product with the local force can be fully expanded:
This result says:
- the chordwise lever arm \(\ell_z\) produces a local \(y\)-axis pitching moment;
- the spanwise lever arm \(\ell_y\) produces a local \(z\)-axis moment;
- the local normal force alone does not produce a local \(x\)-axis moment through this lever arm.
If one temporarily ignores the chordwise offset between the wing-body origin and the centre of mass, i.e. takes
then
This is the rigorous meaning of "implementing \(M_{aero}\) implicitly through the CoP". If the wing-body origin and the centre of mass do not coincide, then
so the \(y\)-axis moment about the centre of mass additionally contains
which is the moment that necessarily appears when the reference point is moved from the wing-body origin to the centre of mass; it is not a double count of the aerodynamic pitching moment.
The rotational damping is a pure moment independent of the centre-of-pressure lever arm:
Hence the total local moment on one wing is
Using the fact that rotation matrices preserve the cross product,
the single-wing wrench can be rotated into the world frame:
This is exactly equivalent to the code's approach of first computing the world-frame CoP and then taking the cross product in the world frame.
7.4 From the left and right single wings to \(F_{\mathrm{lift,total}}\)¶
The world-frame resultant given by the pure aerodynamic functions for the left and right wings is a vector sum, not a sum of absolute values:
If world \(+z\) is defined as vertically upward, the true instantaneous total lift is
Because of the STL normal convention the current adapter flips the forces of both wings, so
and hence
This is exactly the value the adapter finally stores in the diagnostic quantity \(F_{\mathrm{world}}\). Therefore, in the current model, the actual vertical aerodynamic force on the robot should be judged with
The scalar in the code
has a different meaning. It merely adds the magnitudes of the normal forces in the two wing aerodynamic frames. It does not contain the following information:
- the inclination of the wing normal relative to the world vertical;
- whether the horizontal components of the left and right wings cancel or add;
- the global flip the adapter applies for the STL normal;
- any moment produced by \(M_{aero}\), the CoP and \(M_{rd}\).
Let the world-frame vertical projection of each wing's actual normal be
then the actual total vertical aerodynamic force can be written explicitly as
Only when both wing normals are aligned with the vertical and the sign conventions have been unified can it be approximated as
So the \(\text{lift\_total}\) in the code is better used as an "aerodynamic load intensity" diagnostic; it cannot be treated directly as world-frame lift at arbitrary attitude.
An interface-level numerical check also confirmed this relationship: at every step the vector sum of the left and right xfrc_applied[:, :3] equals the adapter's final diagnostics["F_world"] exactly; the norm of the difference in the current check is 0.
7.5 How the total moment of the left and right wings is defined¶
The moments of the two wings cannot simply be added at arbitrary reference points. The moment the code writes for each wing is about that wing's centre of mass. To obtain the total aerodynamic moment of the whole robot about a robot reference point \(O\), use
The current adapter keeps the moment computed by the pure function and flips only the force:
Hence the actual wrench after the adapter must be understood directly in terms of the two quantities above; it can no longer be assumed equivalent to "the flipped force still acting at the original CoP". MuJoCo writes the left and right wrenches into the corresponding wing bodies and transmits them through the joint constraints as generalised forces on the whole robot.
8. From the local force to the MuJoCo world-frame wrench¶
Let \(R_{WA}\) be the rotation matrix from the wing aerodynamic frame to the world frame. The pure function world_wrench computes
The final wrench ordering follows MuJoCo:
With read_only=True all diagnostics are computed as usual, but the wrench returned for xfrc_applied is zero.
8.1 The adapter's actual normal correction¶
The STL wing normal is opposite to the oriented normal of the aerodynamic model. After world_wrench has finished, MuJoCoAeroAdapter performs
That is, only the first three force components are flipped; the r×F moment already computed from the original force is not flipped, nor is the damping moment. This behaviour was intentional for the current URDF/STL coordinate convention, but it means the wrench the adapter finally injects is no longer strictly equivalent to "applying the flipped force at the same CoP". F_world in the diagnostics is also flipped to match the force actually applied. [Translator's note: this force flip was removed on 2026-10-28; see quasi_steady_model.md.]
9. Left/right wing handling and symmetry¶
aero_step uses \(s=+1\) for the left wing and \(s=-1\) for the right wing. The adapter also applies stroke_sign=-1 to the right-wing stroke angle and rate, to bring the mirrored URDF joint into the common aerodynamic sign convention. The sign of the feathering angle is set by pitch_axis_y; the current adapter passes \(+1\).
In the ideal mirrored state the normal forces of the two wings may have opposite signs in their own local frames, but after the mirrored rotation and the adapter's normal correction the vertical components add while the lateral components and part of the moments cancel. The code diagnostic
is only the sum of the absolute local normal forces, not the strict world-frame vertical lift; the actual world resultant should be read from diagnostics["F_world"].
10. Cycle-averaged hover model¶
Free flight with fix=False does not use the per-wing instantaneous model above but AveragedHoverAero (now flapsim/aero/cycle_averaged.py). At the same time the environment treats the robot dynamics as a single rigid body of mass \(m=0.03\ \mathrm{kg}\); the sub-bodies retain only tiny numerical inertias, and the stroke-joint velocities do not take part in dynamic energy injection.
10.1 Mean lift¶
The model is written as
The body-frame thrust direction is set by the plate command:
where \(\delta_p=\texttt{pitch\_joint}\) and \(\delta_r=\texttt{roll\_joint}\). For small angles
so tilting the plate produces horizontal thrust linearly.
The world-frame horizontal damping is
10.2 Mean control moments¶
The identified moments scale with the square of frequency:
The body-frame moment is
where
The URDF naming and the physical axes are crossed: pitch_joint mainly controls the roll moment and roll_joint mainly controls the pitch moment. The expression above therefore contains cross terms, rather than each named angle controlling the axis of the same name.
10.3 Consistency of the hover frequency¶
The weight of the 30 g equivalent mass is
From \(k_f f_h^2=mg\),
This agrees with the nominal hover_frequency=42 Hz of HoveringController (now flapsim/controllers/hover_pid.py). At 42 Hz the model lift is \(0.291224\ \mathrm N\), about \(1.05\%\) below the weight; the remaining steady-state error is compensated by the frequency correction of the altitude PID.
11. Numerical validation¶
The following checks were run on the repository code and the Wing.json current at the time of writing; all units are SI.
11.1 Closed-form integral check of the pure functions¶
Choose the representative state
Integrate the strip formula numerically with 200001 spanwise points and compare with the closed-form geometric-moment formula in wing_aero:
| Check | Closed form (code) | Numerical integration | Relative error |
|---|---|---|---|
| \(F_N\) | 0.0351425732167 N | 0.0351425732172 N | \(1.36\times10^{-11}\) |
| \(M_{aero}\) | -0.000406734027976 N·m | -0.000406734027981 N·m | \(1.36\times10^{-11}\) |
Conclusion: the closed-form integration from the affine velocity expansion to the rxx/z_cpx geometric moments is implemented correctly; the error comes only from the numerical quadrature.
Other algebraic properties verified:
| Property | Result |
|---|---|
| \(r_{cp}=3R/4\) | 0.05250000000000003 m |
| \(-M_{aero}/F_N=c_{cp}\) | both sides 0.0115738260106 m |
| \(M_{rd}\dot\theta\) | \(-1.14133\times10^{-5}\ \mathrm W<0\) |
| Force ratio at equal phase, 15 Hz to 30 Hz | 4.0 |
| Maximum residual of \(C_N\) oddness (1001 points) | 0.0 |
| Maximum residual of \(d_{cp}\) evenness (1001 points) | 0.0 |
11.2 Full fixed-base simulation frequency sweep¶
Set 20, 30 and 40 Hz respectively, run for 0.6 s, discard the first 0.3 s, and average the adapter's actual world-frame vertical force:
| Frequency | Mean \(F_z\) | \(F_z/f^2\) |
|---|---|---|
| 20 Hz | 0.0661819985 N | \(1.65455\times10^{-4}\) N/Hz² |
| 30 Hz | 0.1486352297 N | \(1.65150\times10^{-4}\) N/Hz² |
| 40 Hz | 0.2639114107 N | \(1.64945\times10^{-4}\) N/Hz² |
The 30 Hz result differs from the identification point \(0.1485834904\ \mathrm N\) used by the averaged model by about \(0.035\%\). \(F_z/f^2\) is essentially constant over the three points, showing that the reduction \(L=k_ff^2\) is a reasonable local approximation in the current passive-feathering steady state.
11.3 Free hover check¶
Start the free model from \(z=0.5\ \mathrm m\) and simulate for 2.5 s:
| Metric | Result |
|---|---|
| Final altitude | 0.479012 m |
| Altitude range | 0.470056–0.500000 m |
| Mean altitude over the last 0.5 s | 0.475627 m |
| Mean frequency over the last 0.5 s | 42.234855 Hz |
Conclusion: the averaged model stays bounded and converges near the nominal hover frequency; within the finite time there is still an altitude offset of about 2.4 cm, so this result demonstrates qualitative stability and order-of-magnitude consistency of model and controller, and should not be read as strict zero steady-state error or as validation over the full flight envelope.
12. What has been proven, model assumptions and implementation boundaries¶
12.1 What can be proven rigorously from the implementation or re-checked numerically¶
- Rigid-body kinematics make the two local velocity components first-order functions of the spanwise coordinate;
- the velocity squared is therefore a quadratic polynomial, and the spanwise integral can be completed exactly with precomputed geometric moments;
- without clipping, the moment produced by the chordwise CoP is algebraically equivalent to
M_aero; - the rotational damping always satisfies \(M_{rd}\dot\theta\le0\);
- the pure-flapping normal force at equal phase scales with \(f^2\); the current full fixed-base sweep also supports this approximation;
- the nominal hover frequency of the cycle-averaged model is consistent with balancing the 30 g weight.
12.2 What cannot be called a first-principles proof¶
- \(C_N(\alpha)\) and \(d_{cp}(\alpha)\) are empirical fits whose accuracy needs experimental or higher-fidelity CFD data;
- replacing the spanwise-varying angle of attack by the angle at a single representative point is a quasi-steady reduced-order assumption;
- the fixed \(r_{cp}=Rr_{33}/r_{22}\) is strictly valid only when the velocity squared is dominated by the \(r_w^2\) term;
- the current model does not explicitly include wake capture, added mass, wing–wing interaction, dynamic stall or ground effect;
- the cycle-averaged model is identified only at a finite set of operating points and cannot automatically be extrapolated to arbitrary amplitude, frequency, attitude or high-speed flight.
12.3 Boundaries to watch when reading and modifying the code¶
- Although
Wing.jsoncarries anoptionsblock, the currentMuJoCoAeroAdapterconstructsAeroOptions()directly and does not load the options from the JSON. Hence theforce_limit=2.0in the JSON has no effect; the actual default is the 5.0 N in the Python dataclass. force_rawis clipped butM_aerois not, andchord_CoPis computed from the unclippedforce_raw. Once the force clip triggers,r×F_clippedno longer equals the originalM_aero.- The adapter flips only the final force, not the already computed moment; the pure-function wrench and the wrench actually injected into MuJoCo must therefore be distinguished.
- The angle of attack uses
atan(U_o/U_i)and forces the correction angle to zero when \(U_i\approx0\); near stroke reversal it lacks the full quadrant continuity ofatan2. diagnostics["lift_total"]is the sum of the absolute local normal forces of the two wings; hover should be judged with the world-frameF_world[2], andlift_totalmust not be taken directly as vertical lift.fix=True/Falsedoes not only fix or free the root; it also switches the aerodynamic model and the way the whole-robot mass/inertia is modelled. This model change must be taken into account when comparing the two simulations.
13. Summary¶
The core of the current per-wing aero can be summarised as
The cycle-averaged model further reduces the full periodic response above to
The closed-form integration, the centre-of-pressure identity, the dissipativity of the damping and the frequency-squared law have all been verified algebraically or numerically. The main uncertainty of the model lies not in the integration implementation but in the empirical aerodynamic coefficients, the single-point angle of attack, the fixed spanwise centre of pressure, and the range over which the fixed-base identification extrapolates to free flight.