Skip to content

Commit f7f0d07

Browse files
authored
Merge pull request #1 from wsribunma/control
Fix stabilized mode + add control input type on GUI
2 parents 52995f1 + 9585702 commit f7f0d07

File tree

6 files changed

+447
-106
lines changed

6 files changed

+447
-106
lines changed

cubs2_control/cubs2_control/autolevel_controller.py

Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,24 @@ class AutolevelController:
4343
mode: float = input_var(desc='mode: 0=manual, 1=stabilized')
4444

4545
# === Parameters ===
46-
# Outer loop: angle control (slow, 1-3 Hz)
47-
Kp_phi: float = param(2.5, desc='P gain roll angle (per rad)')
48-
Kp_theta: float = param(2.0, desc='P gain pitch angle (per rad)')
49-
50-
# Inner loop: rate damping (fast, 15-25 rad/s)
51-
Kp_p: float = param(0.6, desc='P gain roll rate (per rad/s)')
52-
Ki_p: float = param(0.0, desc='I gain roll rate')
53-
Kp_q: float = param(0.6, desc='P gain pitch rate (per rad/s)')
54-
Ki_q: float = param(0.0, desc='I gain pitch rate')
46+
# Rate command sensitivity (how much stick deflection causes rate change)
47+
# Stick-to-rate scaling (rad/s per full stick)
48+
stick_rate_scale_phi: float = param(3.5 * np.pi, desc='stick to roll rate (rad/s)')
49+
stick_rate_scale_theta: float = param(2.0 * np.pi, desc='stick to pitch rate (rad/s)')
50+
51+
# Outer loop: auto-level gains (smooth return to level when stick centered)
52+
Kp_phi: float = param(3.0, desc='P gain roll auto-level (per rad error)')
53+
Kp_theta: float = param(2.0, desc='P gain pitch auto-level (per rad error)')
54+
55+
# Inner loop: rate tracking (gyro-based, fast stabilization)
56+
Kp_p: float = param(0.5, desc='P gain roll rate tracking')
57+
Ki_p: float = param(0.15, desc='I gain roll rate tracking')
58+
Kp_q: float = param(0.3, desc='P gain pitch rate tracking')
59+
Ki_q: float = param(0.1, desc='I gain pitch rate tracking')
60+
61+
# Manual mode gyro damping (gentle - helps with oscillations)
62+
Kd_p_manual: float = param(0.15, desc='damping roll rate in manual mode')
63+
Kd_q_manual: float = param(0.15, desc='damping pitch rate in manual mode')
5564

5665
# Yaw damping (passive)
5766
Kp_r: float = param(0.3, desc='P gain yaw rate damping')
@@ -65,10 +74,6 @@ class AutolevelController:
6574
trim_elevator: float = param(0.0, desc='elevator trim offset (rad)')
6675
trim_rudder: float = param(0.0, desc='rudder trim offset (rad)')
6776

68-
# Stick to attitude mapping
69-
stick_to_phi: float = param(np.deg2rad(45), desc='aileron stick to roll ref (rad)')
70-
stick_to_theta: float = param(np.deg2rad(20), desc='elevator stick to pitch ref (rad)')
71-
7277
# Limits
7378
phi_max: float = param(np.deg2rad(50), desc='max bank angle (rad)')
7479
theta_max: float = param(np.deg2rad(30), desc='max pitch angle (rad)')
@@ -131,54 +136,89 @@ def autolevel_controller() -> Model:
131136
# Airspeed from velocity
132137
speed_meas = cy.norm_2(m.vel.sym)
133138

134-
# In stabilized mode, map stick inputs to attitude references
135-
phi_ref_from_stick = m.ail_manual.sym * m.stick_to_phi.sym
136-
theta_ref_from_stick = m.elev_manual.sym * m.stick_to_theta.sym
137-
138-
# Saturate reference angles to max limits
139-
phi_ref_sat = _saturate(phi_ref_from_stick, -m.phi_max.sym, m.phi_max.sym)
140-
theta_ref_sat = _saturate(theta_ref_from_stick, -m.theta_max.sym, m.theta_max.sym)
141-
142-
# Outer loop: angle errors -> rate commands
143-
e_phi = phi_ref_sat - phi
144-
e_theta = theta_ref_sat - theta
145-
146-
p_cmd = m.Kp_phi.sym * e_phi
147-
q_cmd = m.Kp_theta.sym * e_theta
148-
149-
# Inner loop: rate errors
150-
e_p = p_cmd - p_meas
151-
e_q = q_cmd - q_meas
152-
153-
# ODEs: Integral states (only accumulate in stabilized mode)
139+
# SAFE/AS3X Style Stabilization:
140+
# In stabilized mode, stick commands RATE; autopilot auto-levels when stick centered
141+
# This is rate-based stabilization + automatic leveling, not attitude hold
142+
143+
# Stick input to desired rate commands (pilot commands rate directly)
144+
p_cmd_stick = m.ail_manual.sym * m.stick_rate_scale_phi.sym
145+
q_cmd_stick = m.elev_manual.sym * m.stick_rate_scale_theta.sym
146+
147+
# Auto-level: when stick is centered, command returns to level attitude
148+
# Proportional feedback from ACTUAL attitude error to drive back to level (0°)
149+
# This ensures wings-level return regardless of current bank/pitch
150+
p_cmd_level = -m.Kp_phi.sym * phi # Roll error: always drives to phi=0 (wings-level)
151+
q_cmd_level = -m.Kp_theta.sym * theta # Pitch error: always drives to theta=0 (level)
152+
153+
# Apply angle limits to STICK commands in stabilized mode (only limit pilot input)
154+
# If near max bank angle, reduce stick authority to prevent exceeding limit
155+
# Margin factor: 0.9 = start limiting at 90% of max angle
156+
angle_margin = 0.9
157+
phi_saturation_factor = cy.fmin(1.0, (m.phi_max.sym - cy.fabs(phi)) / (m.phi_max.sym * (1.0 - angle_margin)))
158+
theta_saturation_factor = cy.fmin(1.0, (m.theta_max.sym - cy.fabs(theta)) / (m.theta_max.sym * (1.0 - angle_margin)))
159+
160+
# Limit stick-commanded rates by saturation factor (soft limit on pilot input)
161+
p_cmd_stick_limited = p_cmd_stick * phi_saturation_factor
162+
q_cmd_stick_limited = q_cmd_stick * theta_saturation_factor
163+
164+
# In stabilized mode: blend LIMITED stick rate commands with auto-level
165+
# Auto-level is NEVER saturated - it always tries to level wings
166+
# In manual mode: stick directly controls surfaces
167+
p_cmd = p_cmd_stick_limited * (1.0 - m.mode.sym) + (p_cmd_stick_limited + p_cmd_level) * m.mode.sym
168+
q_cmd = q_cmd_stick_limited * (1.0 - m.mode.sym) + (q_cmd_stick_limited + q_cmd_level) * m.mode.sym
169+
170+
# Inner loop: rate tracking (gyro-based rate damping)
171+
# Always active in stabilized mode to track commanded rates
172+
e_p = (p_cmd - p_meas) * m.mode.sym # Rate error (only in stabilized)
173+
e_q = (q_cmd - q_meas) * m.mode.sym # Rate error (only in stabilized)
174+
175+
# In manual mode, provide some gyro damping to improve handling
176+
e_p_manual = -m.Kp_p.sym * p_meas # Damping proportional to rate
177+
e_q_manual = -m.Kp_q.sym * q_meas # Damping proportional to rate
178+
179+
# ODEs: Integral states for rate tracking (only in stabilized mode)
154180
model.ode(m.i_p, e_p * m.mode.sym)
155181
model.ode(m.i_q, e_q * m.mode.sym)
156182

157-
# Stabilized mode control outputs
183+
# Stabilized mode control outputs (rate-based)
184+
# PID rate controller to track commanded rates
185+
ail_attitude_fb = -1.5 * phi # Direct roll error feedback (only for roll leveling)
186+
158187
ail_stabilized = _saturate(
159-
m.Kp_p.sym * e_p + m.Ki_p.sym * m.i_p.sym,
188+
m.Kp_p.sym * e_p + m.Ki_p.sym * m.i_p.sym + ail_attitude_fb,
160189
m.ail_min.sym, m.ail_max.sym
161190
)
162191
elev_stabilized = _saturate(
163192
m.Kp_q.sym * e_q + m.Ki_q.sym * m.i_q.sym,
164193
m.elev_min.sym, m.elev_max.sym
165194
)
195+
196+
# Manual mode outputs (direct pass-through + gentle gyro damping)
197+
ail_manual_out = m.ail_manual.sym + e_p_manual
198+
elev_manual_out = m.elev_manual.sym + e_q_manual
166199
rud_stabilized = _saturate(-m.Kp_r.sym * r_meas, m.rud_min.sym, m.rud_max.sym)
167200
e_speed = m.speed_ref.sym - speed_meas
168201
thr_stabilized = _saturate(m.Kp_speed.sym * e_speed, m.thr_min.sym, m.thr_max.sym)
169202

170-
# Mode switch: 0 = manual (pass-through), 1 = stabilized (autopilot)
171-
# Apply trim offsets to all outputs
203+
# Mode switch: 0 = manual (direct + damping), 1 = stabilized (rate-based auto-level)
204+
# Aileron/Elevator: switch between manual and stabilized
172205
ail_out = (
173-
m.ail_manual.sym * (1.0 - m.mode.sym) + ail_stabilized * m.mode.sym
206+
_saturate(ail_manual_out, m.ail_min.sym, m.ail_max.sym) * (1.0 - m.mode.sym) +
207+
ail_stabilized * m.mode.sym
174208
) + m.trim_aileron.sym
209+
175210
elev_out = (
176-
m.elev_manual.sym * (1.0 - m.mode.sym) + elev_stabilized * m.mode.sym
211+
_saturate(elev_manual_out, m.elev_min.sym, m.elev_max.sym) * (1.0 - m.mode.sym) +
212+
elev_stabilized * m.mode.sym
177213
) + m.trim_elevator.sym
214+
215+
# Rudder: yaw damping in both modes
178216
rud_out = (
179217
m.rud_manual.sym * (1.0 - m.mode.sym) + rud_stabilized * m.mode.sym
180218
) + m.trim_rudder.sym
181-
thr_out = m.thr_manual.sym * (1.0 - m.mode.sym) + thr_stabilized * m.mode.sym
219+
220+
# Throttle: pilot always controls throttle directly
221+
thr_out = m.thr_manual.sym
182222

183223
# Define outputs
184224
model.output(m.ail, ail_out)

cubs2_control/cubs2_control/gamepad_control.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,17 @@ def __init__(self):
159159
self.sub_joy = self.create_subscription(
160160
Joy, '/joy', self.joy_callback, qos_profile)
161161

162+
# Subscribe to external control messages (from RViz dropdown, etc.)
163+
# to sync mode changes from other sources
164+
self.sub_control = self.create_subscription(
165+
AircraftControl, '/control', self.control_callback, 10)
166+
162167
# Current state
163168
self.aileron = 0.0
164169
self.elevator = 0.0
165170
self.throttle = self.throttle_default
166171
self.rudder = 0.0
172+
self.mode = 0 # 0 = manual, 1 = stabilized
167173

168174
# Trim values (applied as offsets to stick inputs)
169175
self.trim_aileron = 0.0
@@ -217,6 +223,8 @@ def __init__(self):
217223
f' Button {self.btn_trim_rud_left} (X): Trim rudder left')
218224
self.get_logger().info(
219225
f' Button {self.btn_trim_rud_right} (Y): Trim rudder right')
226+
self.get_logger().info(
227+
f' Button {self.btn_right_bumper}: Toggle flight mode (manual/stabilized)')
220228
dpad_type = (
221229
'buttons' if self.dpad_is_buttons else f'axes ({
222230
self.axis_dpad_h}, {
@@ -509,6 +517,12 @@ def get_axis(index: int) -> float:
509517
else:
510518
self.trim_hold_count[self.btn_dpad_right] = 0
511519

520+
# Button: Toggle flight mode (manual/stabilized)
521+
if self._button_pressed(msg, self.btn_right_bumper):
522+
self.mode = 1 - self.mode # Toggle between 0 and 1
523+
mode_name = 'STABILIZED' if self.mode == 1 else 'MANUAL'
524+
self.get_logger().info(f'Flight mode changed to: {mode_name}')
525+
512526
# Button: Exit node
513527
if self._button_pressed(msg, self.btn_exit):
514528
self.get_logger().info('Exit button pressed, shutting down...')
@@ -583,6 +597,20 @@ def get_axis(index: int) -> float:
583597
# Publish control messages
584598
self.publish_controls()
585599

600+
def control_callback(self, msg: AircraftControl):
601+
"""
602+
Listen for external control messages (e.g., from RViz dropdown).
603+
604+
Syncs mode changes from other sources so both gamepad and dropdown
605+
control the same mode.
606+
"""
607+
# Update mode if it changed externally (e.g., from RViz dropdown)
608+
external_mode = int(msg.mode)
609+
if external_mode != self.mode:
610+
self.mode = external_mode
611+
mode_name = 'STABILIZED' if self.mode == 1 else 'MANUAL'
612+
self.get_logger().info(f'Flight mode synced to: {mode_name}')
613+
586614
def publish_controls(self):
587615
"""Publish current control state as AircraftControl message."""
588616
msg = AircraftControl()
@@ -591,6 +619,7 @@ def publish_controls(self):
591619
msg.elevator = float(self.elevator)
592620
msg.throttle = float(self.throttle)
593621
msg.rudder = float(self.rudder)
622+
msg.mode = int(self.mode)
594623
self.pub_control.publish(msg)
595624

596625

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 CogniPilot Foundation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Wings-level inner loop stabilization controller."""
16+
from beartype import beartype
17+
from cyecca.dynamics.explicit import explicit
18+
from cyecca.dynamics.explicit import input_var
19+
from cyecca.dynamics.explicit import Model
20+
from cyecca.dynamics.explicit import output_var
21+
from cyecca.dynamics.explicit import param
22+
import cyecca.sym as cy
23+
import numpy as np
24+
25+
26+
@explicit
27+
class WingsLevelController:
28+
"""Inner loop wings-level stabilization - keeps aircraft wings level."""
29+
30+
# === Inputs ===
31+
q: float = input_var(4, desc='quaternion [w,x,y,z]')
32+
omega: float = input_var(3, desc='angular velocity body frame (rad/s)')
33+
ail_manual: float = input_var(desc='manual aileron (rad)')
34+
elev_manual: float = input_var(desc='manual elevator (rad)')
35+
rud_manual: float = input_var(desc='manual rudder (rad)')
36+
thr_manual: float = input_var(desc='manual throttle')
37+
38+
# === Parameters ===
39+
# Roll stabilization (P control on roll angle error)
40+
Kp_phi: float = param(2.0, desc='P gain roll angle control')
41+
42+
# Manual mode gyro damping (gentle - helps with oscillations)
43+
Kp_p: float = param(0.15, desc='damping roll rate in manual mode')
44+
Kp_q: float = param(0.15, desc='damping pitch rate in manual mode')
45+
46+
# Yaw damping (passive)
47+
Kp_r: float = param(0.3, desc='P gain yaw rate damping')
48+
49+
# Trim offsets
50+
trim_aileron: float = param(0.0, desc='aileron trim offset (rad)')
51+
trim_elevator: float = param(0.0, desc='elevator trim offset (rad)')
52+
trim_rudder: float = param(0.0, desc='rudder trim offset (rad)')
53+
54+
# Limits
55+
ail_min: float = param(-0.5, desc='ail min (rad)')
56+
ail_max: float = param(0.5, desc='ail max (rad)')
57+
elev_min: float = param(-0.5, desc='elev min (rad)')
58+
elev_max: float = param(0.5, desc='elev max (rad)')
59+
rud_min: float = param(-0.5, desc='rud min (rad)')
60+
rud_max: float = param(0.5, desc='rud max (rad)')
61+
thr_min: float = param(0.0, desc='thr min')
62+
thr_max: float = param(1.0, desc='thr max')
63+
64+
# === Outputs ===
65+
ail: float = output_var(desc='aileron (rad)')
66+
elev: float = output_var(desc='elevator (rad)')
67+
rud: float = output_var(desc='rudder (rad)')
68+
thr: float = output_var(desc='throttle')
69+
70+
71+
def _saturate(val, low, high):
72+
"""Saturate value between low and high."""
73+
return cy.fmin(cy.fmax(val, low), high)
74+
75+
76+
@beartype
77+
def wings_level_controller() -> Model:
78+
"""
79+
Create wings-level inner loop stabilization controller.
80+
81+
Keeps aircraft wings level by applying proportional feedback on roll angle.
82+
Pitch and throttle are stick pass-through.
83+
84+
Returns
85+
-------
86+
Model
87+
Wings-level controller model
88+
89+
"""
90+
model = Model(WingsLevelController)
91+
m = model.v # Unified namespace
92+
93+
# Extract quaternion and compute Euler angles
94+
qw = m.q.sym[0]
95+
qx = m.q.sym[1]
96+
qy = m.q.sym[2]
97+
qz = m.q.sym[3]
98+
99+
# Roll (phi) - aerospace ZYX sequence
100+
phi = cy.atan2(2.0 * (qw * qx + qy * qz), 1.0 - 2.0 * (qx * qx + qy * qy))
101+
102+
# Extract angular rates
103+
p_meas = m.omega.sym[0]
104+
q_meas = m.omega.sym[1]
105+
r_meas = m.omega.sym[2]
106+
107+
# Roll angle error from level (phi = 0 is wings level)
108+
phi_error = phi
109+
110+
# Aileron: P controller on roll angle error to bring wings to level
111+
ail_stabilized = _saturate(
112+
-m.Kp_phi.sym * phi_error, # Negative feedback: positive phi error → negative aileron
113+
m.ail_min.sym, m.ail_max.sym
114+
)
115+
116+
# Elevator: stick pass-through with rate damping
117+
e_q_manual = -m.Kp_q.sym * q_meas # Gentle gyro damping
118+
elev_manual_out = m.elev_manual.sym + e_q_manual
119+
elev_out = _saturate(elev_manual_out, m.elev_min.sym, m.elev_max.sym) + m.trim_elevator.sym
120+
121+
# Rudder: passive yaw damping
122+
rud_stabilized = _saturate(-m.Kp_r.sym * r_meas, m.rud_min.sym, m.rud_max.sym)
123+
124+
# Throttle: pilot always controls throttle directly
125+
thr_out = m.thr_manual.sym
126+
127+
# Define outputs
128+
model.output(m.ail, ail_stabilized + m.trim_aileron.sym)
129+
model.output(m.elev, elev_out)
130+
model.output(m.rud, rud_stabilized + m.trim_rudder.sym)
131+
model.output(m.thr, thr_out)
132+
133+
model.build()
134+
return model
135+
136+
137+
__all__ = [
138+
'wings_level_controller',
139+
'WingsLevelController',
140+
]

0 commit comments

Comments
 (0)