Lesson 7 — Practice: Stabilizing Aircraft Longitudinal Motion¶
1. Goal¶
In this final workshop, we apply everything learned in practice. Our goal is to design an automatic stabilization system (autopilot) for a simplified longitudinal model of a light aircraft. We’ll go from modeling to a full controller-plus-observer synthesis.
2. Plant Model¶
Consider a linearized short-period longitudinal model. State variables are:
α— angle of attackq— pitch rate
The control input is elevator deflection δₑ (delta_e). The measurable output is the pitch angle θ (theta), here equal to the integral of q.
System matrices (for a particular aircraft and flight condition) can be:
3. Step 1: Analyze the Open-Loop System¶
3.1. Stability¶
First, check the “bare” aircraft (no control) by computing eigenvalues of A.
λ₁,₂ = -0.3695 ± 0.1627j
Both real parts are negative, so the aircraft is statically and dynamically stable. However, the damping (σ = 0.3695) is relatively small, which may cause undesirable oscillations.
3.2. Controllability and Observability¶
Check whether the system is controllable and observable.
- Controllability matrix:
P = [B, AB],rank(P) = 2— fully controllable. - Observability matrix:
Q = [C; CA],rank(Q) = 2— fully observable.
We can proceed with synthesis.
4. Step 2: Controller Synthesis (Pole Placement)¶
We want the system to be more responsive with better damping. Target closed-loop poles:
p₁,₂ = -1.0 ± 1.0j
This yields faster decay (σ = 1.0 vs 0.3695) and higher oscillation frequency.
- Desired characteristic polynomial:
α(s) = (s - p₁)(s - p₂) = s² + 2s + 2. - Use Ackermann’s formula (or equivalent) to find
K.
Result:
K = [-26.4, -15.7]
Control law: δₑ = -([-26.4, -15.7]) · [α; q] = 26.4 α + 15.7 q
5. Step 3: Observer Synthesis¶
Assume α is not directly measurable; only q is measured. We need an observer to estimate α.
Make the observer faster than the controller, e.g., 5× faster:
l₁,₂ = -5.0 ± 5.0j
- Desired observer polynomial:
α_obs(s) = s² + 10s + 50. - Use the dual of Ackermann’s formula to find
L.
This yields the observer gain L.
6. Step 4: Assemble the Full System¶
Full control system consists of:
- State observer:
x̂̇ = A x̂ + B δₑ + L (q - q̂) - Controller:
δₑ = -K x̂
This autopilot uses measured q and commands δₑ to stabilize the aircraft with the desired dynamics set by p₁,₂.
7. Homework¶
- Using MATLAB, Python (
controllibrary), or similar tools, reproduce the computations:- Verify stability, controllability, and observability of the open-loop system.
- Compute
Kfor the specified controller poles. - Compute
Lfor the specified observer poles.
- Build a Simulink model or write a script to simulate:
- The open-loop system.
- The system with controller (true-state feedback).
- The full system (controller + observer).
- Compare transients for all three cases under initial perturbation (e.g., initial
α). Confirm the controller improves behavior vs open-loop, and the full system closely approximates ideal state feedback.
8. References¶
- Aircraft Pitch: State-Space Methods for Controller Design — University of Michigan. – URL: https://ctms.engin.umich.edu/CTMS/index.php?example=AircraftPitch§ion=ControlStateSpace
- Python Control Systems Library. – URL: https://python-control.readthedocs.io/en/latest/
- MATLAB Control System Toolbox. – URL: https://www.mathworks.com/products/control.html
9. Full Python Implementation¶
We implement a complete longitudinal stabilization example with detailed analysis.
import numpy as np
import matplotlib.pyplot as plt
import control as ctrl
from scipy.linalg import eigvals
from scipy.integrate import solve_ivp
class AircraftLongitudinalControl:
"""
Class for modeling and control of longitudinal aircraft motion
"""
def __init__(self):
# Short-period longitudinal model
# States: [alpha, q] - angle of attack and pitch rate
self.A = np.array([[-0.313, 1.0],
[-0.0139, -0.426]])
self.B = np.array([[-0.0322],
[0.0]])
self.C = np.array([[0, 1]]) # we measure only the pitch rate q
self.D = np.array([[0]])
# Analysis parameters
self.n = self.A.shape[0]
print("AIRCRAFT LONGITUDINAL MODEL")
print("="*50)
print("States: [α, q]")
print("α - angle of attack (rad)")
print("q - pitch rate (rad/s)")
print("Control: δe - elevator deflection (rad)")
print("Measurement: q - pitch rate")
print()
print(f"A = \n{self.A}")
print(f"B = \n{self.B}")
print(f"C = \n{self.C}")
def analyze_open_loop(self):
"""Open-loop analysis"""
print(f"\n{'='*60}")
print("OPEN-LOOP ANALYSIS")
print(f"{'='*60}")
# Eigenvalues
poles = eigvals(self.A)
print(f"Open-loop poles: {poles}")
# Stability
stable = all(np.real(pole) < 0 for pole in poles)
print(f"Stability: {'✓ Stable' if stable else '✗ Unstable'}")
# Oscillation characteristics
for i, pole in enumerate(poles):
real_part = np.real(pole)
imag_part = np.imag(pole)
if abs(imag_part) > 1e-6:
freq = abs(imag_part)
damping_ratio = -real_part / abs(pole)
period = 2 * np.pi / freq
print(f"\nPole {i+1}: {pole:.4f}")
print(f" Frequency: {freq:.4f} rad/s")
print(f" Period: {period:.2f} s")
print(f" Damping ratio: {damping_ratio:.4f}")
if period < 5:
print(" Type: Short-period mode")
else:
print(" Type: Long-period mode")
# Controllability and observability
P = ctrl.ctrb(self.A, self.B)
O = ctrl.obsv(self.A, self.C)
controllable = np.linalg.matrix_rank(P) == self.n
observable = np.linalg.matrix_rank(O) == self.n
print(f"\nControllability: {'✓' if controllable else '✗'}")
print(f"Observability: {'✓' if observable else '✗'}")
if not controllable:
print("⚠️ System is uncontrollable! Synthesis impossible.")
if not observable:
print("⚠️ System is unobservable! Observer impossible.")
return poles, controllable, observable
def design_controller(self, desired_poles=None):
"""Controller synthesis via pole placement"""
if desired_poles is None:
desired_poles = [-1.0 + 1.0j, -1.0 - 1.0j] # faster and better damped
print(f"\n{'='*60}")
print("CONTROLLER SYNTHESIS")
print(f"{'='*60}")
print(f"Desired poles: {desired_poles}")
# Place poles
self.K = ctrl.place(self.A, self.B, desired_poles)
# Verify result
A_cl = self.A - self.B @ self.K
actual_poles = eigvals(A_cl)
print(f"Feedback gain K: {self.K}")
print(f"Actual poles: {actual_poles}")
# Control law
print(f"\nControl law:")
print(f"δe = -K * [α; q] = -{self.K[0,0]:.3f}*α - {self.K[0,1]:.3f}*q")
return self.K, actual_poles
def design_observer(self, observer_poles=None):
"""State observer synthesis"""
if observer_poles is None:
# Observer poles 5 times faster than controller poles
observer_poles = [-5.0 + 5.0j, -5.0 - 5.0j]
print(f"\n{'='*60}")
print("OBSERVER SYNTHESIS")
print(f"{'='*60}")
print(f"Desired observer poles: {observer_poles}")
# Observer synthesis (duality)
L_T = ctrl.place(self.A.T, self.C.T, observer_poles)
self.L = L_T.T
# Verify result
A_obs = self.A - self.L @ self.C
actual_observer_poles = eigvals(A_obs)
print(f"Observer gain L: {self.L}")
print(f"Actual observer poles: {actual_observer_poles}")
# Observer equation
print(f"\nObserver equation:")
print(f"α̂' = {self.A[0,0]:.3f}*α̂ + {self.A[0,1]:.3f}*q̂ + {self.B[0,0]:.3f}*δe + {self.L[0,0]:.3f}*(q - q̂)")
print(f"q̂' = {self.A[1,0]:.3f}*α̂ + {self.A[1,1]:.3f}*q̂ + {self.B[1,0]:.3f}*δe + {self.L[1,0]:.3f}*(q - q̂)")
return self.L, actual_observer_poles
def simulate_scenarios(self, t_sim=15, disturbance_time=2):
"""Simulate different control scenarios"""
print(f"\n{'='*60}")
print("SYSTEM SIMULATION")
print(f"{'='*60}")
t = np.linspace(0, t_sim, 1000)
# Initial conditions
alpha0 = 0.1 # initial AoA (rad) ≈ 5.7°
q0 = 0.0 # initial pitch rate
x0_true = np.array([alpha0, q0])
# Initial estimate (inaccurate)
x0_est = np.array([0.0, 0.0])
print(f"Initial conditions:")
print(f" True state: α₀ = {alpha0:.3f} rad ({np.degrees(alpha0):.1f}°), q₀ = {q0:.3f} rad/s")
print(f" Initial estimate: α̂₀ = {x0_est[0]:.3f} rad, q̂₀ = {x0_est[1]:.3f} rad/s")
# Scenario 1: No control
print("\nSimulating: Open-loop system...")
sys_open = ctrl.ss(self.A, np.zeros((2, 1)), np.eye(2), np.zeros((2, 1)))
t1, x1 = ctrl.initial_response(sys_open, t, X0=x0_true, return_x=True)
# Scenario 2: Ideal state feedback
print("Simulating: Ideal feedback...")
A_ideal = self.A - self.B @ self.K
sys_ideal = ctrl.ss(A_ideal, np.zeros((2, 1)), np.eye(2), np.zeros((2, 1)))
t2, x2 = ctrl.initial_response(sys_ideal, t, X0=x0_true, return_x=True)
u2 = -self.K @ x2 # control signal
# Scenario 3: Full system (controller + observer)
print("Simulating: Full system with observer...")
def full_system_dynamics(t, state):
"""Full system dynamics"""
x = state[:2] # true state [α, q]
x_hat = state[2:] # estimated state [α̂, q̂]
# Measurement (pitch rate only)
y = self.C @ x
# Control based on estimate
u = -self.K @ x_hat
# Disturbance (wind gust at disturbance_time)
disturbance = 0.05 if abs(t - disturbance_time) < 0.1 else 0.0
# True system dynamics
dx_dt = self.A @ x + self.B @ u + np.array([disturbance, 0])
# Observer dynamics
dx_hat_dt = self.A @ x_hat + self.B @ u + self.L @ (y - self.C @ x_hat)
return np.concatenate([dx_dt, dx_hat_dt])
# Numerical integration
x0_full = np.concatenate([x0_true, x0_est])
sol = solve_ivp(full_system_dynamics, [0, t_sim], x0_full, t_eval=t, rtol=1e-8)
x3 = sol.y[:2, :]
x_hat3 = sol.y[2:, :]
u3 = -self.K @ x_hat3
error3 = x3 - x_hat3
return {
't': t,
'open_loop': {'t': t1, 'x': x1},
'ideal_feedback': {'t': t2, 'x': x2, 'u': u2},
'full_system': {'t': t, 'x': x3, 'x_hat': x_hat3, 'u': u3, 'error': error3}
}
def plot_results(self, results):
"""Plot simulation results"""
fig, axes = plt.subplots(3, 3, figsize=(18, 12))
t = results['t']
open_loop = results['open_loop']
ideal = results['ideal_feedback']
full = results['full_system']
# 1. Angle of attack
axes[0, 0].plot(open_loop['t'], np.degrees(open_loop['x'][0, :]), 'k--',
linewidth=2, label='Open loop')
axes[0, 0].plot(ideal['t'], np.degrees(ideal['x'][0, :]), 'b-',
linewidth=2, label='Ideal FB')
axes[0, 0].plot(t, np.degrees(full['x'][0, :]), 'r-',
linewidth=2, label='With observer')
axes[0, 0].set_title('Angle of attack α')
axes[0, 0].set_xlabel('Time (s)')
axes[0, 0].set_ylabel('α (deg)')
axes[0, 0].legend()
axes[0, 0].grid(True)
# 2. Pitch rate
axes[0, 1].plot(open_loop['t'], np.degrees(open_loop['x'][1, :]), 'k--',
linewidth=2, label='Open loop')
axes[0, 1].plot(ideal['t'], np.degrees(ideal['x'][1, :]), 'b-',
linewidth=2, label='Ideal FB')
axes[0, 1].plot(t, np.degrees(full['x'][1, :]), 'r-',
linewidth=2, label='With observer')
axes[0, 1].set_title('Pitch rate q')
axes[0, 1].set_xlabel('Time (s)')
axes[0, 1].set_ylabel('q (deg/s)')
axes[0, 1].legend()
axes[0, 1].grid(True)
# 3. Control input
axes[0, 2].plot(ideal['t'], np.degrees(ideal['u'][0, :]), 'b-',
linewidth=2, label='Ideal FB')
axes[0, 2].plot(t, np.degrees(full['u'][0, :]), 'r-',
linewidth=2, label='With observer')
axes[0, 2].set_title('Elevator deflection δe')
axes[0, 2].set_xlabel('Time (s)')
axes[0, 2].set_ylabel('δe (deg)')
axes[0, 2].legend()
axes[0, 2].grid(True)
# 4. Estimation error — angle of attack
axes[1, 0].plot(t, np.degrees(full['error'][0, :]), 'g-', linewidth=2)
axes[1, 0].set_title('Estimation error of α')
axes[1, 0].set_xlabel('Time (s)')
axes[1, 0].set_ylabel('α - α̂ (deg)')
axes[1, 0].grid(True)
# 5. Estimation error — pitch rate
axes[1, 1].plot(t, np.degrees(full['error'][1, :]), 'g-', linewidth=2)
axes[1, 1].set_title('Estimation error of pitch rate')
axes[1, 1].set_xlabel('Time (s)')
axes[1, 1].set_ylabel('q - q̂ (deg/s)')
axes[1, 1].grid(True)
# 6. True state vs estimate
axes[1, 2].plot(t, np.degrees(full['x'][0, :]), 'b-', linewidth=2, label='True α')
axes[1, 2].plot(t, np.degrees(full['x_hat'][0, :]), 'r--', linewidth=2, label='Estimate α̂')
axes[1, 2].set_title('Comparison: true vs estimate (α)')
axes[1, 2].set_xlabel('Time (s)')
axes[1, 2].set_ylabel('α (deg)')
axes[1, 2].legend()
axes[1, 2].grid(True)
# 7. Phase portrait
axes[2, 0].plot(np.degrees(open_loop['x'][0, :]), np.degrees(open_loop['x'][1, :]),
'k--', linewidth=2, label='Open loop')
axes[2, 0].plot(np.degrees(ideal['x'][0, :]), np.degrees(ideal['x'][1, :]),
'b-', linewidth=2, label='Ideal FB')
axes[2, 0].plot(np.degrees(full['x'][0, :]), np.degrees(full['x'][1, :]),
'r-', linewidth=2, label='With observer')
axes[2, 0].plot(np.degrees(full['x'][0, 0]), np.degrees(full['x'][1, 0]),
'ko', markersize=8, label='Initial point')
axes[2, 0].set_title('Phase portrait')
axes[2, 0].set_xlabel('α (deg)')
axes[2, 0].set_ylabel('q (deg/s)')
axes[2, 0].legend()
axes[2, 0].grid(True)
# 8. Pole map
poles_open = eigvals(self.A)
poles_controller = eigvals(self.A - self.B @ self.K)
poles_observer = eigvals(self.A - self.L @ self.C)
axes[2, 1].plot(np.real(poles_open), np.imag(poles_open), 'ko',
markersize=10, label='Open loop')
axes[2, 1].plot(np.real(poles_controller), np.imag(poles_controller), 'bs',
markersize=8, label='Controller')
axes[2, 1].plot(np.real(poles_observer), np.imag(poles_observer), 'r^',
markersize=8, label='Observer')
axes[2, 1].axvline(x=0, color='k', linestyle='--', alpha=0.5)
axes[2, 1].axvspan(-8, 0, alpha=0.2, color='green', label='Stable region')
axes[2, 1].set_title('Pole map')
axes[2, 1].set_xlabel('Real part')
axes[2, 1].set_ylabel('Imag part')
axes[2, 1].legend()
axes[2, 1].grid(True)
# 9. Quality analysis
# Settling time (2% criterion)
final_alpha_ideal = ideal['x'][0, -1]
final_alpha_full = full['x'][0, -1]
settling_idx_ideal = np.where(np.abs(ideal['x'][0, :] - final_alpha_ideal) <=
0.02 * abs(final_alpha_ideal))[0]
settling_idx_full = np.where(np.abs(full['x'][0, :] - final_alpha_full) <=
0.02 * abs(final_alpha_full))[0]
settling_time_ideal = ideal['t'][settling_idx_ideal[0]] if len(settling_idx_ideal) > 0 else ideal['t'][-1]
settling_time_full = t[settling_idx_full[0]] if len(settling_idx_full) > 0 else t[-1]
systems = ['Ideal FB', 'With observer']
settling_times = [settling_time_ideal, settling_time_full]
axes[2, 2].bar(systems, settling_times, alpha=0.7, color=['blue', 'red'])
axes[2, 2].set_title('Settling time')
axes[2, 2].set_ylabel('Time (s)')
axes[2, 2].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Print numerical results
print(f"\n{'='*60}")
print("RESULTS ANALYSIS")
print(f"{'='*60}")
print(f"Settling time (ideal): {settling_time_ideal:.2f} s")
print(f"Settling time (observer): {settling_time_full:.2f} s")
print(f"Difference: {abs(settling_time_full - settling_time_ideal):.2f} s")
max_error_alpha = np.max(np.abs(full['error'][0, :]))
max_error_q = np.max(np.abs(full['error'][1, :]))
print(f"Max estimation error α: {np.degrees(max_error_alpha):.3f}°")
print(f"Max estimation error q: {np.degrees(max_error_q):.3f}°/s")
max_control_ideal = np.max(np.abs(ideal['u'][0, :]))
max_control_full = np.max(np.abs(full['u'][0, :]))
print(f"Max elevator deflection (ideal): {np.degrees(max_control_ideal):.1f}°")
print(f"Max elevator deflection (observer): {np.degrees(max_control_full):.1f}°")
def main():
"""Main function — full example"""
# Create control system object
aircraft = AircraftLongitudinalControl()
# Analyze open-loop system
poles, controllable, observable = aircraft.analyze_open_loop()
if not (controllable and observable):
print("System is not suitable for synthesis!")
return
# Controller synthesis
K, controller_poles = aircraft.design_controller()
# Observer synthesis
L, observer_poles = aircraft.design_observer()
# Simulation
results = aircraft.simulate_scenarios()
# Plot results
aircraft.plot_results(results)
print(f"\n{'='*60}")
print("CONCLUSION")
print(f"{'='*60}")
print("✓ Successfully synthesized an automatic control system")
print("✓ System is stable with good dynamic performance")
print("✓ Observer provides accurate estimates of unmeasured states")
print("✓ Observer-based control approaches ideal state feedback")
if __name__ == "__main__":
main()
Homework¶
# Additional self-study experiments
def extended_experiments():
"""Additional experiments"""
aircraft = AircraftLongitudinalControl()
# 1. Experiment with different desired poles
pole_variants = [
[-0.5 + 0.5j, -0.5 - 0.5j], # slow
[-2 + 2j, -2 - 2j], # fast
[-1, -2], # aperiodic
[-0.7 + 1.4j, -0.7 - 1.4j] # lightly damped
]
print("Task 1: Study the effect of different controller poles")
for i, poles in enumerate(pole_variants):
print(f"Variant {i+1}: {poles}")
# TODO: Implement synthesis and comparison
# 2. Measurement noise experiment
print("\nTask 2: Add measurement noise and assess robustness")
# TODO: Add white noise to q measurements
# 3. Parameter uncertainty experiment
print("\nTask 3: Study the effect of model uncertainty")
# TODO: Vary A elements within ±20%
# 4. Compare with a PID controller
print("\nTask 4: Compare with a classical PID controller")
# TODO: Implement PID and compare results
# Run these experiments for deeper exploration!
This complete example demonstrates:
- Object-oriented approach to control system design
- Full development cycle from analysis to simulation
- Detailed visualization of all aspects of system operation
- Practical recommendations for parameter tuning
- Self-study exercises for reinforcing the material