Skip to content

Repository files navigation

AeroTune — Aircraft control-system design, simulated in 3D

Live demo Client-side only License

TypeScript React 18 react-three-fiber Vite Zero external math libraries

A browser-based 3D simulator for teaching aircraft control system design. Pick a mission, read the aircraft model and its requirements, tune a controller with sliders, run the simulation, and watch the aircraft fly the manoeuvre in 3D while the game scores what your design actually achieved.

Everything runs client-side. No server, no API, no language model, no network access at runtime. The mission content is bundled JSON; the numerics are plain TypeScript, and every aircraft model traces back to real flight-dynamics derivatives rather than a hand-tuned transfer function (see Where the aircraft models come from).

Built for two audiences at once: students and engineers who want to feel what a phase margin or an LQR weight actually does to an aeroplane, and developers who want a from-scratch, dependency-light implementation of eigenvalue solvers, Riccati equations and pole placement to read or extend.

AeroTune demo: tuning a controller and flying the manoeuvre in 3D

Requirements

  • Node.js 18+ (developed against 22)
  • A browser with WebGL2 for the 3D viewport

Quick start

npm install
npm run dev                  # http://localhost:5173
npm test                     # numerics + headless render checks
npm run test:solvability     # confirm every mission is actually beatable (slow)
npm run build                # static bundle in dist/

The separation this project is built around

This project's original design spec draws a hard line: a content generator defines missions, and the game computes results. That line is enforced here.

A mission file may declare the plant, the flight condition, the command, the limits, the requirements, the hints, the feedback strings and the scoring weights. It may not contain an achieved settling time, an overshoot figure, pole locations, a stability verdict or a score. validateMission fails any file carrying a key that looks like a pre-computed result (achieved_*, actual_*, player_score, closed_loop_poles, …), and every number the UI shows in an "achieved" column comes from src/engine.

Feedback strings are likewise conditional by construction: they are keyed by the condition that triggers them (unstable, excessive_overshoot, …) and are selected at runtime from measured telemetry, never asserted by the file.


Layout

src/
  types/mission.ts      Mission JSON schema
  missions/*.json       Six missions; index.ts bundles them
  engine/
    linalg.ts           Matrices, eigenvalues (balance → Hessenberg → shifted QR),
                        complex solve, Kronecker, Lyapunov
    poly.ts             Polynomial algebra, ss2tf (Faddeev–LeVerrier), PID/lead-lag
    systems.ts          State-space type, tf2ss, controllability, observability
    riccati.ts          CARE via matrix sign function + Kleinman polish; LQR
    placement.ts        Ackermann pole placement, observer placement by duality
    freqresp.ts         Bode sweep, gain and phase margins
    controllers.ts      Design (sliders → gains) and runtime (gains → commands)
    simulate.ts         Fixed-step RK4 closed loop, actuator/sensor/disturbance models
    metrics.ts          Rise, settling, overshoot, steady-state error, envelope
    evaluate.ts         Test battery, requirement checks, scoring, feedback selection
    mission.ts          Validation + compilation to SI runtime form
    units.ts            SI internally; degrees only at the display boundary
    visualState.ts      Telemetry → 3D attitude/position track
  ui/                   React panels, R3F viewport, procedural aircraft, HUD
tests/
  run.ts                Numerics against analytic results + mission validation
  smoke.tsx             Headless React render of every panel
  solvability.ts        Searches each mission's design space for a compliant design

The six missions:

Mission Difficulty Model Controllers offered
Pitch Attitude Hold: Basic Trainer beginner short period → transfer function P, PD, PID
Roll Attitude Capture: Survey UAV beginner roll subsidence → transfer function P, PD, PID
Pitch Attitude Command in Turbulent Air intermediate 4-state longitudinal PID, pole placement, state feedback, LQR
Bank Angle Hold in Turbulence intermediate 4-state lateral-directional state feedback, LQR
Pitch Command with a Single Sensor advanced 4-state longitudinal, attitude-only measurement LQR
Coordinated Bank with Rudder Degradation advanced 4-state, 2-input, with in-flight failure LQR

Every mission's model is derived from airframe data. None contain a hand-written A matrix. The two beginner missions are still transfer functions, as the spec requires for that level, but their coefficients are computed by reducing the derived state-space model rather than being written out by hand: the trainer keeps the short-period approximation, the UAV keeps roll subsidence only.


Where the aircraft models come from

No mission contains an A matrix. Each contains an aeroplane with geometry, mass, inertia tensor, and non-dimensional stability derivatives, and src/engine/aero.ts derives the state-space model from it at load time. Missions that want a reduced model ask for one by name (short_period, roll) and those that want a transfer function get it by collapsing the derived model, so even a beginner SISO plant traces back to the same physics.

wing area, span, chord          ISA density at altitude
mass, Ixx/Iyy/Izz/Ixz     +     true airspeed              ->  q̄ = ½ρV²
CLα, Cmα, Cmq, Cmδe, …                                     ->  dimensional derivatives
                                                           ->  A, B

Longitudinal models carry the α̇ downwash lag properly (Z_ẇ on the left-hand side of the w equation, substituted into the pitching moment), and lateral models fold the Ixz roll–yaw coupling into primed derivatives. Gust columns are physics too: a vertical gust of w m/s is an incidence change of w/u₀, so E is the α column of A divided by u₀ rather than a hand-written vector that can drift out of step with A.

This matters because it makes the models checkable. npm run test:aero verifies the ISA against published values and every assembled model against the classical reduced-order approximations, which are derived from the same derivatives by a completely different route:

Check Agreement
Short period vs two-state reduction within 5%
Roll subsidence vs primed roll damping within 5%
Dutch roll vs two-state reduction within 15–20%
Phugoid vs Lanchester estimate within 30%
Spiral vs its approximation, and against the stability criterion within 35%, sign must agree

Tolerances differ by mode deliberately. The short-period and roll reductions are near-exact for a conventional aeroplane, the Lanchester phugoid is known to be loose, so demanding tight agreement there would be testing the approximation rather than the model.

It also tests the scaling laws: short-period frequency rising linearly with airspeed, M_α halving when pitch inertia doubles, and the exact two-term density mixture. That last one is worth spelling out, because the tempting guess is wrong: ωₙ² = (Zα M_q/u₀) − Mα, and those terms scale as ρ² and ρ respectively, so frequency falls faster than √ρ with altitude.

The validator additionally enforces physical consistency: CLα > 0, Cmα < 0 for static stability, Cmq < 0, Cnβ > 0 for weathercock stability, Clβ < 0 for dihedral effect, a realisable inertia tensor, geometry where span × chord is consistent with wing area, and, for longitudinal models, that the declared trim CL₀ actually equals W/(q̄S) at the stated weight, altitude and speed.

These remain teaching models: representative of their class and internally consistent, not manufacturer data.

Simulation model

Each run integrates the augmented [plant; actuator] state with fixed-step RK4. Actuator position and rate limits are enforced inside the integration, not clipped afterwards for display, so a saturated surface genuinely starves the loop of authority. Sensor noise, bias, transport delay and sample-and-hold are applied to the feedback signal before the controller sees it.

Pressing Run executes the test battery the mission declares:

Test What it does
Nominal Commanded manoeuvre, ideal sensors, still air, actuator limits active
Disturbance Same command with the mission's gusts, turbulence or failures
Sensor noise Noise, bias and delay applied to the feedback signal
Parameter uncertainty Worst-corner sweep over the declared ±% on A and B, gains unchanged

Noise and turbulence are driven by a generator seeded from the mission id, so the realisation is identical between runs. A score that moves did so because the gains changed, not because the dice did.

Metric conventions

Following stepinfo and most first-course textbooks:

  • Rise time is 10% to 90% of the achieved change.
  • Settling time and overshoot are measured against the response's own final value, so a slow-but-offset response is penalised through steady-state error rather than twice.
  • Steady-state error is measured against the command.
  • Margins are properties of the linear loop and therefore exclude actuator saturation, which is stated in the UI wherever they appear.
  • Disturbance response is isolated by differencing the disturbed run against the nominal one, not by measuring |reference - output|. Otherwise continuous turbulence starting at t = 0 would report the commanded step as its "peak deviation".
  • Rate saturation is only penalised once sustained. Every step command produces a brief slew at maximum surface rate; marking that down would teach the wrong lesson. Position saturation is penalised in proportion to duration.

Visualization conventions

The 3D view is presentational. The linearised scenario models say nothing about where the aircraft is in space, so the flight path is integrated from the attitude they do produce. Three deliberate distortions, all labelled in the UI:

  • Control-surface deflection is drawn 3× actual. A good design moves the elevator a few degrees, which is invisible at model scale. The HUD always prints the true deflection and the viewport footer states the exaggeration.
  • Altitude is log-compressed for the ground plane's depth, because the missions span 400 m to 10 km and no linear mapping keeps both readable. Departures from the trim altitude are then amplified so a climb or descent is visible. The HUD altitude is always the true value.
  • The attitude indicator is the primary instrument. A pitch ladder, bank scale and a yellow command bug make a 5° tracking error something you can see; judging that from the model silhouette alone is not realistic.

When the state leaves the range where a small-perturbation model means anything (roughly 25° of pitch, 60° of bank or 15° of angle of attack), the viewport says so explicitly rather than presenting a nose-down dive as a real prediction.


Controllers

Method Sliders exposed
P / PI / PD / PID Kp, Ki, Kd, derivative filter corner; anti-windup and derivative-on-measurement toggles
Lead-lag Gain, zero and pole location (implemented, but no mission enables it; see below)
Pole placement Damping ratio, natural frequency, non-dominant pole spread, observer speed
State feedback One gain per state, plus an integral-of-error gain and observer speed
LQR One Q weight per state, control weight R, integral weight, observer speed

When a mission does not measure the full state, the observer-speed slider becomes active and a Luenberger estimator is built from the available measurement and fed to the control law.

Raw gain vectors are not exposed for LQR or pole placement, per the technical report: you set the intent (weights, or where the poles should go) and the solver returns the gains, which are displayed so you can see what your intent cost.

Three deliberate choices worth knowing about:

  • Gains are synthesised against the plant alone, the way a student would work on paper. The reported closed-loop poles then include the actuator lag. That gap between what you designed for and what you got is part of the lesson.
  • Slider ranges scale to the aircraft. Pole-placement frequency spans a multiple of the plant's own fastest mode, and PID gain ranges derive from its low-frequency gain. A fixed range would put almost all of the travel in the region where the surface is saturated and nothing useful happens.
  • Not every method is offered on every mission. A mission only lists a controller that can actually meet its requirements, which the solvability sweep verifies. Two missions are restricted as a result: advanced_observer_pitch offers LQR alone because Ackermann's formula is numerically unusable on a plant that stiff, and intermediate_lateral_turbulence drops pole placement because three sliders (ζ, ωₙ, spread) have to fix all five closed-loop poles, and no point in that space meets the mission's speed and actuator-authority constraints together; 0 of 560 grid points satisfied everything. Offering a method that cannot pass would be worse than omitting it.

Not currently exposed, and worth knowing before you go looking for them:

  • Gain scheduling / multiple operating points. Listed as an optional extension in the original design spec. Not implemented; every mission is a single operating point.
  • Lead-lag is implemented and unit-tested in the engine, but no shipped mission enables it. A grid search over the intermediate pitch mission found no first-order compensator meeting that mission's requirements; the damping floor is the binding constraint and it fails at the large majority of grid points. Adding an option that cannot pass would be worse than leaving it off, so it needs a mission written around it.
  • The anti-windup limit slider suggested in the original spec is a toggle here; the tracking time constant is derived from Kp/Ki rather than set by hand.

Verification

npm test runs two suites, and npm run test:solvability runs a third:

tests/run.ts checks the numerics against values that can be derived by hand: inverses and linear solves; eigenvalues of diagonal, complex-pair and block-diagonal matrices; polyRoots on a factored cubic; a Lyapunov solution; tf2ss/ss2tf round trips; LQR against the analytic scalar solution and the double integrator's K = [1, √3]; a Riccati residual on a stable spiral; Ackermann on a double integrator (K = [2, 2]) and pole-landing accuracy on a 3×3; phase margin of 1/(s(s+1)) and the 15.56 dB gain margin of 1/(s(s+1)(s+2)); a second-order overshoot against exp(-πζ/√(1-ζ²)). It then validates every mission file and confirms each mission runs with each of its own allowed controllers.

tests/reference-solutions.ts is the one that matters most day to day. For every mission/controller pair the game offers it stores a design known to satisfy every requirement, and asserts that it still does and that it sits inside the slider ranges a student can actually reach. All 14 offered pairs currently pass, scoring between 95.5 and 100. If an engine change quietly makes a mission unbeatable, this fails in seconds rather than going unnoticed.

tests/smoke.tsx renders every panel headlessly against a real evaluated run.

tests/solvability.ts answers the question static validation cannot: are the requirements actually achievable? It runs random-restart coordinate descent over each mission's slider space for each of its allowed controllers, and reports both the best reachable score and whether any design meeting every requirement exists. A mission with no compliant design anywhere in its slider space is unsolvable and fails the check. This is the slow, exhaustive search (tens of minutes) used when authoring or changing a mission; the reference-solutions test below is the fast check on the answers it finds.

This sweep earned its place. It caught, among other things:

  • A phantom pole at the origin: pidTf always emitted the s(s+N) denominator, so a P controller carried uncancelled pole/zero pairs and every stable proportional design reported as unstable.
  • Ackermann silently returning an unstable design. On the advanced four-state model the phugoid and short-period modes are two decades apart, and augmenting for integral action pushes the controllability matrix to a condition number near 10⁵. Ackermann returned gains of ±1180 that placed a pole at +29.9 in the right half plane while reporting success. ackermann now verifies that the poles actually landed where they were asked and reports failure with an explanation when they did not, and that mission offers LQR only.
  • Pole-placement sliders spanning 0.2–20 rad/s regardless of aircraft, putting nearly the whole travel in the region where Ackermann returns enormous gains and the surface is pinned against its stop.
  • State-feedback sliders that could not reach the answer. Their range was derived from the plant's low-frequency gain, the right reference for a PID gain acting on error, the wrong one for a gain acting on a state. On the lateral mission it collapsed the range to ±1 while a design scoring 100 needed −2, so no amount of careful tuning could reach it. The range now derives from a unit-weight LQR solution, and a test asserts the sliders can always express it.
  • A disturbance metric that measured the commanded step rather than the gust, because continuous turbulence starts at t = 0 and |reference - output| is dominated by the manoeuvre itself.
  • Four missions declaring a rise-time limit while awarding no points for it, so a design could fail a stated requirement and still score full marks.
  • A near-miss rounding up: category points were rounded to the nearest tenth, so a design at 99.7% of a category's maximum displayed as full marks and could total a perfect 100 while a requirement was failing. Points now floor.
  • Requirement comparisons failing on floating-point noise, because degree limits round-trip through radians and a value the simulator clamped to exactly the limit came back a few ULP above it.

The sweep is a lower bound, not a proof: a "no compliant design" report means investigate, since local search can miss a compliant region. It did exactly that once, reporting the beginner roll mission unsolvable by PID when the compliant point simply sat outside its basin. This is why the search now seeds from the all-minimum corner (zeroing a controller's optional terms) before trying random restarts.


Deviations from the original design spec

Two, both deliberate:

  1. mathjs is not used, though the original technical spec recommended it. It has no continuous-time Riccati solver, no gain/phase margin routine, and unreliable eigenvalues for non-symmetric matrices. All three are core to this app. The dedicated linalg module replaces it and is unit-tested against analytic results, which the recommendation's intent (don't scatter ad-hoc matrix code) is better served by.

  2. Riccati is solved by the matrix sign function, not by integrating the differential Riccati equation. The DRE approach is simpler to write but needs tens of thousands of steps on a stiff model. The advanced mission has a phugoid and a short-period mode two decades apart, and LQR design took seconds, which makes a live slider preview impossible. The sign function converges in roughly a dozen iterations regardless of scaling; design synthesis now takes about 1 ms.

The remaining open questions from the original spec (3D asset style, live vs. scrubbable playback, session persistence) are resolved with that document's own stated defaults: procedural low-poly aircraft, precompute-then-scrub playback, and ephemeral sessions with no persistence.


Authoring a mission

Drop a JSON file in src/missions/ and add it to index.ts. validateMission enforces, among other things: matrix dimensions agree with the declared state, input and output vectors; state names carry SI unit suffixes and never degrees; the flight condition is physically plausible; rise-time and settling-time limits are not mutually contradictory; the settling limit fits inside the run; actuator travel and rate limits are sane; scoring categories sum to exactly 100; three hints exist; a disclaimer exists; and nothing in the file references a URL.

Errors block the mission from loading and are shown on its card. Warnings (for instance, a beginner mission using a state-space model) are surfaced but do not block.


Disclaimer

The aircraft models here are simplified for education. This is not a flight-certification tool, the results are estimated rather than exact, and no controller designed here should be used on a real aircraft. Real flight control systems require verification, validation, testing, redundancy and certification.


Contributing

Bug reports, mission proposals and engine improvements are welcome. If you're adding or changing a mission, run npm run test:solvability for it before opening a PR — an unsolvable mission is treated as a bug. If you're touching src/engine, npm test should stay green; it checks the numerics against values derivable by hand, not just against the app's own output.

Contributors

Built by two students at Gebze Technical University — one from the software side, one from the aircraft side, which is why the engine insists on deriving every model from real stability derivatives instead of a hand-picked transfer function.

Tuğberk Akbulut
Tuğberk Akbulut

Computer Engineering, GTU
Mehmet Muaz Tunç
Mehmet Muaz Tunç

Aeronautical Engineering, GTU

License

MIT

About

Browser-based 3D aircraft control-system design simulator, tune PID/LQR/pole-placement controllers against real flight-dynamics models, entirely client-side.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages