From c919c2c1ebf41b6cc2a4beef3c471dca51dba01c Mon Sep 17 00:00:00 2001 From: RickyRicato Date: Tue, 5 May 2026 22:07:18 +0800 Subject: [PATCH 01/10] Add actuator dynamics in TVC and ThrottleControl --- .../halcyon_flight_sim_roll_throttle.ipynb | 22 ++++--- rocketpy/rocket/rocket.py | 15 +++++ rocketpy/rocket/throttle_control.py | 35 ++++++++++- rocketpy/rocket/tvc.py | 61 +++++++++++++++++-- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/docs/examples/halcyon_flight_sim_roll_throttle.ipynb b/docs/examples/halcyon_flight_sim_roll_throttle.ipynb index 6a6ede8fa..d33ecb012 100644 --- a/docs/examples/halcyon_flight_sim_roll_throttle.ipynb +++ b/docs/examples/halcyon_flight_sim_roll_throttle.ipynb @@ -408,12 +408,15 @@ " # state = [x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]\n", "\n", " # Throttle command: 0.5 Hz sinusoid, centered at 0.8, amplitude 0.2\n", - " throttle_control.throttle = 0.8 + 0.2 * np.sin(2 * np.pi * 0.5 * time)\n", + " throttle_command = 0.8 + 0.2 * np.sin(2 * np.pi * 0.5 * time)\n", + " throttle_control.throttle = throttle_command\n", + " throttle_actual = throttle_control.throttle\n", "\n", " # Return variables of interest to be saved in the observed_variables list\n", " return (\n", " time,\n", - " throttle_control.throttle,\n", + " throttle_command,\n", + " throttle_actual,\n", " )" ] }, @@ -428,7 +431,8 @@ " sampling_rate=100,\n", " throttle_range=(0.0, 1.0),\n", " throttle_rate_limit=100,\n", - " initial_throttle=1.0,\n", + " actuator_tau=0.2,\n", + " throttle=1.0,\n", " return_controller=True,\n", " clamp=True,\n", ")\n", @@ -461,22 +465,24 @@ " verbose=False,\n", ")\n", "# test_flight.plots.attitude_data()\n", - "# ===== 先看 throttle 有沒有真的變 =====\n", + "# ===== Plot commanded throttle vs filtered actuator output =====\n", "obs = np.array(throttle_controller.observed_variables)\n", "\n", "t_cmd = obs[:, 0]\n", "th_cmd = obs[:, 1]\n", + "th_act = obs[:, 2]\n", "\n", "plt.figure()\n", - "plt.plot(t_cmd, th_cmd, label=\"Throttle\")\n", + "plt.plot(t_cmd, th_cmd, label=\"Throttle command\")\n", + "plt.plot(t_cmd, th_act, label=\"Filtered throttle\")\n", "plt.xlabel(\"Time (s)\")\n", "plt.ylabel(\"Throttle\")\n", - "plt.title(\"Throttle Command\")\n", + "plt.title(\"Throttle Command vs Actuator Output\")\n", "plt.legend()\n", "plt.grid()\n", "plt.show()\n", "\n", - "# ===== 再看推力有沒有跟著變 =====\n", + "# ===== Then check thrust response =====\n", "plt.figure()\n", "plt.plot(\n", " test_flight.net_thrust[:, 0], test_flight.net_thrust[:, 1], label=\"Effective Thrust\"\n", @@ -532,7 +538,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv (3.12.10)", "language": "python", "name": "python3" }, diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 4df77c6db..b8c4ff743 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1836,6 +1836,8 @@ def add_tvc( clamp=True, initial_observed_variables=None, return_controller=False, + actuator_tau_x=True, + actuator_tau_y=True, name="TVC", controller_name="TVC Controller", ): @@ -1902,6 +1904,12 @@ def add_tvc( return_controller : bool, optional If True, the function will return the controller object created. Default is False. + actuator_tau_x : float, optional + Time constant for the x-axis actuator dynamics in seconds. + If None, no actuator dynamics are applied on the x axis. + actuator_tau_y : float, optional + Time constant for the y-axis actuator dynamics in seconds. + If None, no actuator dynamics are applied on the y axis. name : string, optional TVC system name. Has no impact in simulation, as it is only used to display data in a more organized matter. Default is "TVC". @@ -1935,6 +1943,8 @@ def add_tvc( clamp=clamp, gimbal_angle_x=0, gimbal_angle_y=0, + actuator_tau_x=actuator_tau_x, + actuator_tau_y=actuator_tau_y, name=name, ) _controller = _Controller( @@ -2084,6 +2094,7 @@ def add_throttle_control( throttle_rate_limit=0, initial_observed_variables=None, return_controller=False, + actuator_tau=True, name="Throttle Control", controller_name="Throttle Controller", ): @@ -2144,6 +2155,9 @@ def add_throttle_control( throttle_rate_limit : float, optional Maximum throttle rate in 1/s. Throttle is limited to this rate. Must be non-negative. Default is 0 (no throttle change limit). + actuator_tau : float, optional + Time constant for the throttle actuator dynamics in seconds. + If None, no actuator dynamics are applied. initial_observed_variables : list, optional A list of the initial values of the variables that the controller function manages. This list is used to initialize the @@ -2186,6 +2200,7 @@ def add_throttle_control( clamp=clamp, sampling_rate=sampling_rate, throttle_rate_limit=throttle_rate_limit, + actuator_tau=actuator_tau, name=name, ) diff --git a/rocketpy/rocket/throttle_control.py b/rocketpy/rocket/throttle_control.py index eb9cd6af7..518f87e23 100644 --- a/rocketpy/rocket/throttle_control.py +++ b/rocketpy/rocket/throttle_control.py @@ -33,6 +33,7 @@ def __init__( throttle_rate_limit=0, clamp=True, throttle=1.0, + actuator_tau=True, name="Throttle Control", ): """Initializes the ThrottleControl class. @@ -55,6 +56,10 @@ def __init__( exceeds the range. Default is True. throttle : float, optional Initial throttle value. Default is 1.0 (full throttle). + actuator_tau : float, optional + Time constant for the actuator dynamics (first-order IIR filter) + in seconds. If None, no actuator dynamics are applied. + Must be non-negative. Default is None. name : str, optional Name of the throttle control system. Default is "Throttle Control". @@ -71,11 +76,30 @@ def __init__( assert throttle_rate_limit >= 0, "throttle_rate_limit must be non-negative." self.throttle_rate_limit = throttle_rate_limit self.clamp = clamp + # Actuator dynamics parameters + if actuator_tau is not None: + assert actuator_tau >= 0, "actuator_tau must be non-negative." + self.actuator_tau = actuator_tau + # Compute IIR filter coefficients (first-order system) + self._update_iir_coefficients() self.initial_throttle = throttle self.throttle_prev = throttle + self.throttle_filtered = throttle self.throttle = throttle self.prints = _ThrottleControlPrints(self) + def _update_iir_coefficients(self): + """Updates the IIR filter coefficient based on time constant and + sampling rate. Uses first-order discrete-time system: + y[n] = alpha * u[n] + (1 - alpha) * y[n-1] + where alpha = Ts / (tau + Ts) + """ + sampling_period = 1.0 / self.sampling_rate + if self.actuator_tau is not None and self.actuator_tau > 0: + self._alpha = sampling_period / (self.actuator_tau + sampling_period) + else: + self._alpha = 1.0 # No filtering, direct pass-through + @property def throttle(self): return self._throttle @@ -105,14 +129,19 @@ def throttle(self, value): if abs(throttle_change) > max_throttle_change: value = self.throttle_prev + np.sign(throttle_change) * max_throttle_change self.throttle_prev = value - self._throttle = value + # Apply first-order IIR actuator dynamics + self.throttle_filtered = ( + self._alpha * value + (1 - self._alpha) * self.throttle_filtered + ) + self._throttle = self.throttle_filtered def _reset(self): """Resets the throttle control system to its initial state. This method is called at the beginning of each simulation to ensure the throttle control system is in the correct state.""" - self.throttle = self.initial_throttle self.throttle_prev = self.initial_throttle + self.throttle_filtered = self.initial_throttle + self.throttle = self.initial_throttle def info(self): """Prints summarized information of the throttle control system. @@ -130,6 +159,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "throttle_rate_limit": self.throttle_rate_limit, "clamp": self.clamp, "throttle": self.throttle, + "actuator_tau": self.actuator_tau, "name": self.name, } @@ -141,5 +171,6 @@ def from_dict(cls, data): throttle_rate_limit=data.get("throttle_rate_limit"), clamp=data.get("clamp"), throttle=data.get("throttle"), + actuator_tau=data.get("actuator_tau"), name=data.get("name"), ) diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py index b5216ed5e..2f4e6603a 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/tvc.py @@ -39,6 +39,8 @@ def __init__( clamp=True, gimbal_angle_x=0.0, gimbal_angle_y=0.0, + actuator_tau_x=True, + actuator_tau_y=True, name="TVC", ): """Initializes the TVC class. @@ -66,6 +68,14 @@ def __init__( gimbal_angle_y : float, optional Initial gimbal angle around the y-axis (yaw) in degrees. Default is 0.0 (no deflection). + actuator_tau_x : float, optional + Time constant for the x-axis actuator dynamics (first-order IIR + filter) in seconds. If None, no actuator dynamics are applied. + Must be non-negative. Default is None. + actuator_tau_y : float, optional + Time constant for the y-axis actuator dynamics (first-order IIR + filter) in seconds. If None, no actuator dynamics are applied. + Must be non-negative. Default is None. name : str, optional Name of the TVC system. Default is "TVC". @@ -80,14 +90,43 @@ def __init__( assert gimbal_rate_limit >= 0, "gimbal_rate_limit must be non-negative." self.gimbal_rate_limit = gimbal_rate_limit self.clamp = clamp + # Actuator dynamics parameters + if actuator_tau_x is not None: + assert actuator_tau_x >= 0, "actuator_tau_x must be non-negative." + if actuator_tau_y is not None: + assert actuator_tau_y >= 0, "actuator_tau_y must be non-negative." + self.actuator_tau_x = actuator_tau_x + self.actuator_tau_y = actuator_tau_y + # Compute IIR filter coefficients (first-order system) + self._update_iir_coefficients() self.initial_gimbal_angle_x = gimbal_angle_x self.initial_gimbal_angle_y = gimbal_angle_y self.gimbal_angle_x_prev = gimbal_angle_x self.gimbal_angle_y_prev = gimbal_angle_y + self.gimbal_angle_x_filtered = gimbal_angle_x + self.gimbal_angle_y_filtered = gimbal_angle_y self.gimbal_angle_x = gimbal_angle_x self.gimbal_angle_y = gimbal_angle_y self.prints = _TVCPrints(self) + def _update_iir_coefficients(self): + """Updates the IIR filter coefficients based on time constants and + sampling rate. Uses first-order discrete-time system: + y[n] = alpha * u[n] + (1 - alpha) * y[n-1] + where alpha = Ts / (tau + Ts) + """ + sampling_period = 1.0 / self.sampling_rate + # X-axis coefficients + if self.actuator_tau_x is not None and self.actuator_tau_x > 0: + self._alpha_x = sampling_period / (self.actuator_tau_x + sampling_period) + else: + self._alpha_x = 1.0 # No filtering, direct pass-through + # Y-axis coefficients + if self.actuator_tau_y is not None and self.actuator_tau_y > 0: + self._alpha_y = sampling_period / (self.actuator_tau_y + sampling_period) + else: + self._alpha_y = 1.0 # No filtering, direct pass-through + @property def gimbal_angle_x(self): """Returns the current gimbal angle around the x-axis (pitch).""" @@ -111,7 +150,11 @@ def gimbal_angle_x(self, value): if abs(angle_change) > max_angle_change: value = self.gimbal_angle_x_prev + np.sign(angle_change) * max_angle_change self.gimbal_angle_x_prev = value - self._gimbal_angle_x = value + # Apply first-order IIR actuator dynamics + self.gimbal_angle_x_filtered = ( + self._alpha_x * value + (1 - self._alpha_x) * self.gimbal_angle_x_filtered + ) + self._gimbal_angle_x = self.gimbal_angle_x_filtered @property def gimbal_angle_y(self): @@ -136,7 +179,11 @@ def gimbal_angle_y(self, value): if abs(angle_change) > max_angle_change: value = self.gimbal_angle_y_prev + np.sign(angle_change) * max_angle_change self.gimbal_angle_y_prev = value - self._gimbal_angle_y = value + # Apply first-order IIR actuator dynamics + self.gimbal_angle_y_filtered = ( + self._alpha_y * value + (1 - self._alpha_y) * self.gimbal_angle_y_filtered + ) + self._gimbal_angle_y = self.gimbal_angle_y_filtered @property def gimbal_angles(self): @@ -159,10 +206,12 @@ def _reset(self): """Resets the TVC system to its initial state. This method is called at the beginning of each simulation to ensure the TVC system is in the correct state.""" - self.gimbal_angle_x = self.initial_gimbal_angle_x - self.gimbal_angle_y = self.initial_gimbal_angle_y self.gimbal_angle_x_prev = self.initial_gimbal_angle_x self.gimbal_angle_y_prev = self.initial_gimbal_angle_y + self.gimbal_angle_x_filtered = self.initial_gimbal_angle_x + self.gimbal_angle_y_filtered = self.initial_gimbal_angle_y + self.gimbal_angle_x = self.initial_gimbal_angle_x + self.gimbal_angle_y = self.initial_gimbal_angle_y def info(self): """Prints summarized information of the TVC system. @@ -190,6 +239,8 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "clamp": self.clamp, "gimbal_angle_x": self.initial_gimbal_angle_x, "gimbal_angle_y": self.initial_gimbal_angle_y, + "actuator_tau_x": self.actuator_tau_x, + "actuator_tau_y": self.actuator_tau_y, "name": self.name, } @@ -202,5 +253,7 @@ def from_dict(cls, data): clamp=data.get("clamp"), gimbal_angle_x=data.get("gimbal_angle_x"), gimbal_angle_y=data.get("gimbal_angle_y"), + actuator_tau_x=data.get("actuator_tau_x"), + actuator_tau_y=data.get("actuator_tau_y"), name=data.get("name"), ) From 1cf76b8ed71850d405e184aa426b08b3b2fbe62f Mon Sep 17 00:00:00 2001 From: RickyRicato Date: Tue, 5 May 2026 22:23:08 +0800 Subject: [PATCH 02/10] Refactor actuator_tau parameters to use None as default in TVC and ThrottleControl --- rocketpy/rocket/rocket.py | 6 +++--- rocketpy/rocket/throttle_control.py | 2 +- rocketpy/rocket/tvc.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index b8c4ff743..0b5101bd3 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1836,8 +1836,8 @@ def add_tvc( clamp=True, initial_observed_variables=None, return_controller=False, - actuator_tau_x=True, - actuator_tau_y=True, + actuator_tau_x=None, + actuator_tau_y=None, name="TVC", controller_name="TVC Controller", ): @@ -2094,7 +2094,7 @@ def add_throttle_control( throttle_rate_limit=0, initial_observed_variables=None, return_controller=False, - actuator_tau=True, + actuator_tau=None, name="Throttle Control", controller_name="Throttle Controller", ): diff --git a/rocketpy/rocket/throttle_control.py b/rocketpy/rocket/throttle_control.py index 518f87e23..904b53f5f 100644 --- a/rocketpy/rocket/throttle_control.py +++ b/rocketpy/rocket/throttle_control.py @@ -33,7 +33,7 @@ def __init__( throttle_rate_limit=0, clamp=True, throttle=1.0, - actuator_tau=True, + actuator_tau=None, name="Throttle Control", ): """Initializes the ThrottleControl class. diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py index 2f4e6603a..7a7c5964f 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/tvc.py @@ -39,8 +39,8 @@ def __init__( clamp=True, gimbal_angle_x=0.0, gimbal_angle_y=0.0, - actuator_tau_x=True, - actuator_tau_y=True, + actuator_tau_x=None, + actuator_tau_y=None, name="TVC", ): """Initializes the TVC class. From cb78b68b8931f6cfadd545170eee8c51f1c89982 Mon Sep 17 00:00:00 2001 From: RickyRicato Date: Sat, 30 May 2026 15:09:01 +0800 Subject: [PATCH 03/10] Add actuator dynamics to RollControl --- rocketpy/rocket/rocket.py | 5 ++ rocketpy/rocket/roll_control.py | 35 +++++++++- tests/unit/rocket/test_roll_control.py | 97 ++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/unit/rocket/test_roll_control.py diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 0b5101bd3..0c6d79c99 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1972,6 +1972,7 @@ def add_roll_control( return_controller=False, name="Roll Control", controller_name="Roll Control Controller", + actuator_tau=None, ): """Creates a new roll control system, storing its parameters such as maximum roll torque, controller function, and sampling rate. @@ -2042,6 +2043,9 @@ def add_roll_control( Controller name. Has no impact in simulation, as it is only used to display data in a more organized matter. Default is "Roll Control Controller". + actuator_tau : float, optional + Time constant for the roll torque actuator dynamics in seconds. + If None, no actuator dynamics are applied. Returns ------- @@ -2068,6 +2072,7 @@ def add_roll_control( torque_rate_limit=torque_rate_limit, clamp=clamp, roll_torque=0, + actuator_tau=actuator_tau, name=name, ) _controller = _Controller( diff --git a/rocketpy/rocket/roll_control.py b/rocketpy/rocket/roll_control.py index 7629da7d3..662f24530 100644 --- a/rocketpy/rocket/roll_control.py +++ b/rocketpy/rocket/roll_control.py @@ -35,6 +35,7 @@ def __init__( clamp=True, roll_torque=0.0, name="Roll Control", + actuator_tau=None, ): """Initializes the RollControl class. @@ -57,6 +58,10 @@ def __init__( Initial roll torque in N·m. Default is 0.0 (no torque). name : str, optional Name of the roll control system. Default is "Roll Control". + actuator_tau : float, optional + Time constant for the torque actuator dynamics (first-order IIR + filter) in seconds. If None, no actuator dynamics are applied. + Must be non-negative. Default is None. Returns ------- @@ -69,11 +74,30 @@ def __init__( assert torque_rate_limit >= 0, "torque_rate_limit must be non-negative." self.torque_rate_limit = torque_rate_limit self.clamp = clamp + # Actuator dynamics parameters + if actuator_tau is not None: + assert actuator_tau >= 0, "actuator_tau must be non-negative." + self.actuator_tau = actuator_tau + # Compute IIR filter coefficients (first-order system) + self._update_iir_coefficients() self.initial_roll_torque = roll_torque self.roll_torque_prev = roll_torque + self.roll_torque_filtered = roll_torque self.roll_torque = roll_torque self.prints = _RollControlPrints(self) + def _update_iir_coefficients(self): + """Updates the IIR filter coefficient based on time constant and + sampling rate. Uses first-order discrete-time system: + y[n] = alpha * u[n] + (1 - alpha) * y[n-1] + where alpha = Ts / (tau + Ts) + """ + sampling_period = 1.0 / self.sampling_rate + if self.actuator_tau is not None and self.actuator_tau > 0: + self._alpha = sampling_period / (self.actuator_tau + sampling_period) + else: + self._alpha = 1.0 # No filtering, direct pass-through + @property def roll_torque(self): """Returns the current roll torque in N·m.""" @@ -103,14 +127,19 @@ def roll_torque(self, value): if abs(torque_change) > max_torque_change: value = self.roll_torque_prev + np.sign(torque_change) * max_torque_change self.roll_torque_prev = value - self._roll_torque = value + # Apply first-order IIR actuator dynamics + self.roll_torque_filtered = ( + self._alpha * value + (1 - self._alpha) * self.roll_torque_filtered + ) + self._roll_torque = self.roll_torque_filtered def _reset(self): """Resets the roll control system to its initial state. This method is called at the beginning of each simulation to ensure the roll control system is in the correct state.""" - self.roll_torque = self.initial_roll_torque self.roll_torque_prev = self.initial_roll_torque + self.roll_torque_filtered = self.initial_roll_torque + self.roll_torque = self.initial_roll_torque def info(self): """Prints summarized information of the roll control system. @@ -137,6 +166,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "torque_rate_limit": self.torque_rate_limit, "clamp": self.clamp, "roll_torque": self.initial_roll_torque, + "actuator_tau": self.actuator_tau, "name": self.name, } @@ -148,5 +178,6 @@ def from_dict(cls, data): torque_rate_limit=data.get("torque_rate_limit"), clamp=data.get("clamp"), roll_torque=data.get("roll_torque"), + actuator_tau=data.get("actuator_tau"), name=data.get("name"), ) diff --git a/tests/unit/rocket/test_roll_control.py b/tests/unit/rocket/test_roll_control.py new file mode 100644 index 000000000..41be9d3ed --- /dev/null +++ b/tests/unit/rocket/test_roll_control.py @@ -0,0 +1,97 @@ +import pytest + +from rocketpy.rocket.roll_control import RollControl +from rocketpy.rocket.rocket import Rocket + + +def test_roll_torque_actuator_dynamics_filters_command(): + roll_control = RollControl( + sampling_rate=10, + max_roll_torque=100, + torque_rate_limit=1000, + roll_torque=0, + actuator_tau=0.4, + ) + + roll_control.roll_torque = 10 + + assert roll_control.roll_torque == pytest.approx(2) + assert roll_control.roll_torque_prev == pytest.approx(10) + assert roll_control.roll_torque_filtered == pytest.approx(2) + + roll_control.roll_torque = 10 + + assert roll_control.roll_torque == pytest.approx(3.6) + + +def test_roll_torque_without_actuator_dynamics_is_pass_through(): + roll_control = RollControl( + sampling_rate=10, + max_roll_torque=100, + torque_rate_limit=1000, + roll_torque=0, + ) + + roll_control.roll_torque = 10 + + assert roll_control.roll_torque == pytest.approx(10) + + +def test_roll_control_reset_restores_filtered_state(): + roll_control = RollControl( + sampling_rate=10, + max_roll_torque=100, + torque_rate_limit=1000, + roll_torque=5, + actuator_tau=0.4, + ) + roll_control.roll_torque = 25 + + roll_control._reset() + + assert roll_control.roll_torque == pytest.approx(5) + assert roll_control.roll_torque_prev == pytest.approx(5) + assert roll_control.roll_torque_filtered == pytest.approx(5) + + +def test_roll_control_serializes_actuator_tau(): + roll_control = RollControl( + sampling_rate=20, + max_roll_torque=50, + torque_rate_limit=200, + clamp=False, + roll_torque=3, + actuator_tau=0.2, + name="Test Roll Control", + ) + + data = roll_control.to_dict() + restored = RollControl.from_dict(data) + + assert data["actuator_tau"] == pytest.approx(0.2) + assert restored.actuator_tau == pytest.approx(0.2) + assert restored.roll_torque == pytest.approx(3) + + +def test_rocket_add_roll_control_forwards_actuator_tau(): + def controller_function(*_): + return None + + rocket = Rocket( + radius=0.1, + mass=10, + inertia=(1, 1, 1), + power_off_drag=0.5, + power_on_drag=0.5, + center_of_mass_without_motor=0, + ) + + roll_control = rocket.add_roll_control( + max_roll_torque=20, + torque_rate_limit=100, + controller_function=controller_function, + sampling_rate=50, + actuator_tau=0.3, + ) + + assert roll_control.actuator_tau == pytest.approx(0.3) From bbc5cd30a927cb7277673945ca05424bd7d0028c Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sat, 6 Jun 2026 17:27:41 +0100 Subject: [PATCH 04/10] Move actuators to a new actuator folder and rename actuator classes --- .../{roll_control.py => actuator/roll.py} | 8 ++++---- .../throttle.py} | 6 +++--- .../{tvc.py => actuator/thrust_vector.py} | 6 +++--- rocketpy/rocket/rocket.py | 18 +++++++++--------- tests/unit/rocket/test_roll_control.py | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) rename rocketpy/rocket/{roll_control.py => actuator/roll.py} (96%) rename rocketpy/rocket/{throttle_control.py => actuator/throttle.py} (97%) rename rocketpy/rocket/{tvc.py => actuator/thrust_vector.py} (98%) diff --git a/rocketpy/rocket/roll_control.py b/rocketpy/rocket/actuator/roll.py similarity index 96% rename from rocketpy/rocket/roll_control.py rename to rocketpy/rocket/actuator/roll.py index 662f24530..0cca1429a 100644 --- a/rocketpy/rocket/roll_control.py +++ b/rocketpy/rocket/actuator/roll.py @@ -2,14 +2,14 @@ import numpy as np -from ..prints.roll_control_prints import _RollControlPrints +from rocketpy.prints.roll_control_prints import _RollControlPrints -class RollControl: - """Roll Control system class for managing rocket roll torque. +class RollActuator: + """Roll actuator class as a controllable component. This class represents a roll control system that allows the application - of roll torque around the rocket's X-axis. Ideal roll torque is assumed. + of roll torque around the rocket's Z-axis. Ideal roll torque is assumed. Attributes ---------- diff --git a/rocketpy/rocket/throttle_control.py b/rocketpy/rocket/actuator/throttle.py similarity index 97% rename from rocketpy/rocket/throttle_control.py rename to rocketpy/rocket/actuator/throttle.py index 904b53f5f..0b4c8ddb1 100644 --- a/rocketpy/rocket/throttle_control.py +++ b/rocketpy/rocket/actuator/throttle.py @@ -2,11 +2,11 @@ import numpy as np -from ..prints.throttle_control_prints import _ThrottleControlPrints +from rocketpy.prints.throttle_control_prints import _ThrottleControlPrints -class ThrottleControl: - """Throttle Control system class for managing rocket throttle. +class ThrottleActuator: + """Throttle actuator class as a controllable component. This class represents a throttle control system that allows the application of throttle around the rocket's engine. Ideal throttle is assumed. diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/actuator/thrust_vector.py similarity index 98% rename from rocketpy/rocket/tvc.py rename to rocketpy/rocket/actuator/thrust_vector.py index 7a7c5964f..c03630c24 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/actuator/thrust_vector.py @@ -2,11 +2,11 @@ import numpy as np -from ..prints.tvc_prints import _TVCPrints +from rocketpy.prints.tvc_prints import _TVCPrints -class TVC: - """Thrust Vector Control (TVC) system class used as a controllable component. +class ThrustVectorActuator: + """Thrust vector acuatot class as a controllable component. This class represents a thrust vector control system that allows deflection of the thrust vector through gimbal angles. TVC is typically controlled diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 0c6d79c99..6f974edfa 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -25,9 +25,9 @@ from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute -from rocketpy.rocket.roll_control import RollControl -from rocketpy.rocket.throttle_control import ThrottleControl -from rocketpy.rocket.tvc import TVC +from rocketpy.rocket.actuator.roll import RollActuator +from rocketpy.rocket.actuator.throttle import ThrottleActuator +from rocketpy.rocket.actuator.thrust_vector import ThrustVectorActuator from rocketpy.tools import ( deprecated, find_obj_from_hash, @@ -1933,10 +1933,10 @@ def add_tvc( self._controllers = [ controller for controller in self._controllers - if not isinstance(controller.interactive_objects, TVC) + if not isinstance(controller.interactive_objects, ThrustVectorActuator) ] - tvc = TVC( + tvc = ThrustVectorActuator( sampling_rate=sampling_rate, gimbal_range=gimbal_range, gimbal_rate_limit=gimbal_rate_limit, @@ -2063,10 +2063,10 @@ def add_roll_control( self._controllers = [ controller for controller in self._controllers - if not isinstance(controller.interactive_objects, RollControl) + if not isinstance(controller.interactive_objects, RollActuator) ] - roll_control = RollControl( + roll_control = RollActuator( sampling_rate=sampling_rate, max_roll_torque=max_roll_torque, torque_rate_limit=torque_rate_limit, @@ -2196,10 +2196,10 @@ def add_throttle_control( self._controllers = [ controller for controller in self._controllers - if not isinstance(controller.interactive_objects, ThrottleControl) + if not isinstance(controller.interactive_objects, ThrottleActuator) ] - throttle_control = ThrottleControl( + throttle_control = ThrottleActuator( throttle_range=throttle_range, throttle=throttle, clamp=clamp, diff --git a/tests/unit/rocket/test_roll_control.py b/tests/unit/rocket/test_roll_control.py index 41be9d3ed..17181e3b9 100644 --- a/tests/unit/rocket/test_roll_control.py +++ b/tests/unit/rocket/test_roll_control.py @@ -1,6 +1,6 @@ import pytest -from rocketpy.rocket.roll_control import RollControl +from rocketpy.rocket.actuator.roll import RollControl from rocketpy.rocket.rocket import Rocket From 3656f7e0927c6c27dc7be7cb2f0ba1ee50dc610a Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sat, 6 Jun 2026 23:05:08 +0100 Subject: [PATCH 05/10] Refactor roll, throttle, and thrust vector acutator with a new actuator class --- rocketpy/prints/roll_actuator_prints.py | 35 ++ rocketpy/prints/roll_control_prints.py | 30 -- rocketpy/prints/throttle_actuator_prints.py | 35 ++ rocketpy/prints/throttle_control_prints.py | 15 - .../prints/thrust_vector_actuator_prints.py | 41 ++ rocketpy/prints/tvc_prints.py | 29 -- rocketpy/rocket/actuator/__init__.py | 4 + rocketpy/rocket/actuator/actuator.py | 164 +++++++ rocketpy/rocket/actuator/roll.py | 172 +++---- rocketpy/rocket/actuator/throttle.py | 207 ++++----- rocketpy/rocket/actuator/thrust_vector.py | 418 +++++++++--------- rocketpy/rocket/rocket.py | 6 +- 12 files changed, 646 insertions(+), 510 deletions(-) create mode 100644 rocketpy/prints/roll_actuator_prints.py delete mode 100644 rocketpy/prints/roll_control_prints.py create mode 100644 rocketpy/prints/throttle_actuator_prints.py delete mode 100644 rocketpy/prints/throttle_control_prints.py create mode 100644 rocketpy/prints/thrust_vector_actuator_prints.py delete mode 100644 rocketpy/prints/tvc_prints.py create mode 100644 rocketpy/rocket/actuator/__init__.py create mode 100644 rocketpy/rocket/actuator/actuator.py diff --git a/rocketpy/prints/roll_actuator_prints.py b/rocketpy/prints/roll_actuator_prints.py new file mode 100644 index 000000000..d0026b8d0 --- /dev/null +++ b/rocketpy/prints/roll_actuator_prints.py @@ -0,0 +1,35 @@ +class _RollActuatorPrints: + """Class that contains all roll actuator prints.""" + + def __init__(self, roll_actuator): + """Initializes _RollActuatorPrints class + + Parameters + ---------- + roll_actuator: rocketpy.RollActuator + Instance of the RollActuator class. + + Returns + ------- + None + """ + self.roll_actuator = roll_actuator + + def basics(self): + """Prints information of the roll actuator.""" + print(f"Information of {self.roll_actuator.name}:") + print("----------------------------------") + print(f"Torque demand rate: {self.roll_actuator.demand_rate} Hz") + print( + f"Torque range: {self.roll_actuator.actuator_range[0]:.2f} to {self.roll_actuator.actuator_range[1]:.2f} N·m" + ) + print(f"Torque rate limit: {self.roll_actuator.actuator_rate_limit} N·m/s") + print( + f"Torque clamping: {'Enabled' if self.roll_actuator.clamp else 'Disabled'}" + ) + print(f"Torque time constant: {self.roll_actuator.actuator_time_constant} sec") + print(f"Current roll torque: {self.roll_actuator.roll_torque:.2f} N·m") + + def all(self): + """Prints all information of the roll actuator.""" + self.basics() diff --git a/rocketpy/prints/roll_control_prints.py b/rocketpy/prints/roll_control_prints.py deleted file mode 100644 index 8d0123b9a..000000000 --- a/rocketpy/prints/roll_control_prints.py +++ /dev/null @@ -1,30 +0,0 @@ -class _RollControlPrints: - """Class that contains all Roll Control prints.""" - - def __init__(self, roll_control): - """Initializes _RollControlPrints class - - Parameters - ---------- - roll_control: rocketpy.RollControl - Instance of the RollControl class. - - Returns - ------- - None - """ - self.roll_control = roll_control - - def basics(self): - """Prints information of the Roll Control system.""" - print("Information of the Roll Control System:") - print("----------------------------------") - print(f"Maximum Roll Torque: {self.roll_control.max_roll_torque:.2f} N·m") - print(f"Current Roll Torque: {self.roll_control.roll_torque:.2f} N·m") - print( - f"Torque Clamping: {'Enabled' if self.roll_control.clamp else 'Disabled'}" - ) - - def all(self): - """Prints all information of the Roll Control system.""" - self.basics() diff --git a/rocketpy/prints/throttle_actuator_prints.py b/rocketpy/prints/throttle_actuator_prints.py new file mode 100644 index 000000000..3d74d6cb3 --- /dev/null +++ b/rocketpy/prints/throttle_actuator_prints.py @@ -0,0 +1,35 @@ +class _ThrottleActuatorPrints: + def __init__(self, throttle_actuator): + """Initializes _ThrottleActuatorPrints class + + Parameters + ---------- + throttle_actuator: rocketpy.ThrottleActuator + Instance of the ThrottleActuator class. + + Returns + ------- + None + """ + self.throttle_actuator = throttle_actuator + + def basics(self): + """Prints information of the throttle actuator.""" + print(f"Information of {self.throttle_actuator.name}:") + print("----------------------------------") + print(f"Throttle demand rate: {self.throttle_actuator.demand_rate} Hz") + print( + f"Throttle range: {self.throttle_actuator.actuator_range[0]:.2f} to {self.throttle_actuator.actuator_range[1]:.2f}" + ) + print(f"Throttle rate limit: {self.throttle_actuator.actuator_rate_limit}") + print( + f"Throttle Clamping: {'Enabled' if self.throttle_actuator.clamp else 'Disabled'}" + ) + print( + f"Throttle time constant: {self.throttle_actuator.actuator_time_constant} sec" + ) + print(f"Current throttle: {self.throttle_actuator.throttle:.2f}") + + def all(self): + """Prints all information of the throttle actuator.""" + self.basics() diff --git a/rocketpy/prints/throttle_control_prints.py b/rocketpy/prints/throttle_control_prints.py deleted file mode 100644 index 6e86bd090..000000000 --- a/rocketpy/prints/throttle_control_prints.py +++ /dev/null @@ -1,15 +0,0 @@ -class _ThrottleControlPrints: - def __init__(self, throttle_control): - self.roll_control = throttle_control - - def basics(self): - """Prints information of the Throttle Control system.""" - print("Information of the Throttle Control System:") - print("----------------------------------") - print() - print() - print() - - def all(self): - """Prints all information of the Throttle Control system.""" - self.basics() diff --git a/rocketpy/prints/thrust_vector_actuator_prints.py b/rocketpy/prints/thrust_vector_actuator_prints.py new file mode 100644 index 000000000..a99f7989e --- /dev/null +++ b/rocketpy/prints/thrust_vector_actuator_prints.py @@ -0,0 +1,41 @@ +class _ThrustVectorActuatorPrints: + """Class that contains all thrust vector actuator prints.""" + + def __init__(self, thrust_vector_actuator): + """Initializes _ThrustVectorActuatorPrints class + + Parameters + ---------- + thrust_vector_actuator: rocketpy.ThrustVectorActuator + Instance of the thrust vector actuator class. + + Returns + ------- + None + """ + self.thrust_vector_actuator = thrust_vector_actuator + + def basics(self): + """Prints information of the thrust vector actuator.""" + print(f"Information of {self.thrust_vector_actuator.name}:") + print("----------------------------------") + print(f"Gimbal demand rate: {self.thrust_vector_actuator.demand_rate} Hz") + print( + f"Gimbal range: {self.thrust_vector_actuator.actuator_range[0]:.2f} to {self.thrust_vector_actuator.actuator_range[1]:.2f} deg" + ) + print( + f"Gimbal rate limit: {self.thrust_vector_actuator.actuator_rate_limit} deg/sec" + ) + print( + f"Gimbal clamping: {'Enabled' if self.thrust_vector_actuator.clamp else 'Disabled'}" + ) + print( + f"Gimbal time constant: {self.thrust_vector_actuator.actuator_time_constant} sec" + ) + print( + f"Current gimbal angle: {self.thrust_vector_actuator.gimbal_angle:.2f} deg" + ) + + def all(self): + """Prints all information of the thrust vector actuator.""" + self.basics() diff --git a/rocketpy/prints/tvc_prints.py b/rocketpy/prints/tvc_prints.py deleted file mode 100644 index 03ed6acda..000000000 --- a/rocketpy/prints/tvc_prints.py +++ /dev/null @@ -1,29 +0,0 @@ -class _TVCPrints: - """Class that contains all TVC prints.""" - - def __init__(self, tvc): - """Initializes _TVCPrints class - - Parameters - ---------- - tvc: rocketpy.TVC - Instance of the TVC class. - - Returns - ------- - None - """ - self.tvc = tvc - - def geometry(self): - """Prints geometric information of the TVC system.""" - print("Geometric information of the TVC System:") - print("----------------------------------") - print(f"Maximum Gimbal Angle: {self.tvc.gimbal_range:.2f}°") - print(f"Current Gimbal Angle X: {self.tvc.gimbal_angle_x:.2f}°") - print(f"Current Gimbal Angle Y: {self.tvc.gimbal_angle_y:.2f}°") - print(f"Angle Clamping: {'Enabled' if self.tvc.clamp else 'Disabled'}") - - def all(self): - """Prints all information of the TVC system.""" - self.geometry() diff --git a/rocketpy/rocket/actuator/__init__.py b/rocketpy/rocket/actuator/__init__.py new file mode 100644 index 000000000..044590c5b --- /dev/null +++ b/rocketpy/rocket/actuator/__init__.py @@ -0,0 +1,4 @@ +from rocketpy.rocket.actuator.actuator import Actuator +from rocketpy.rocket.actuator.roll import RollActuator +from rocketpy.rocket.actuator.throttle import ThrottleActuator +from rocketpy.rocket.actuator.thrust_vector import ThrustVectorActuator diff --git a/rocketpy/rocket/actuator/actuator.py b/rocketpy/rocket/actuator/actuator.py new file mode 100644 index 000000000..eeb4cda9d --- /dev/null +++ b/rocketpy/rocket/actuator/actuator.py @@ -0,0 +1,164 @@ +from abc import ABC, abstractmethod + +import numpy as np + + +class Actuator(ABC): + """Abstract class used to define actuators. + + Actuators are used to model the dynamics of control systems such as + throttle, thrust vector, and roll control. They can be used to simulate the response of + the control system to changes in throttle, thrust vector, or roll torque commands.""" + + def __init__( + self, + name, + demand_rate=None, + actuator_range=(-np.inf, np.inf), + actuator_rate_limit=None, + clamp=True, + actuator_initial_output=0.0, + actuator_time_constant=None, + ): + """Initializes the Actuator class. + + Parameters + ---------- + name : str + Name of the actuator. + demand_rate : float, optional + Demand rate (Hz) of the actuator. Default is None for continuous-time actuator. + actuator_range : tuple, optional + Range of the actuator output. Default is (-np.Inf, np.Inf). + actuator_rate_limit : float, optional + Rate limit of the actuator per second. Default is None. + clamp : bool, optional + Whether to clamp the actuator output. Default is True. + actuator_initial_output : float, optional + Initial output of the actuator. Default is 0.0. + actuator_time_constant : float, optional + Time constant of the actuator, implemented as a discrete IIR filter. Default is None. + + Returns + ------- + None + """ + + self.name = name + + assert demand_rate > 0 or demand_rate is None, ( + "demand_rate must be positive or None." + ) + self.demand_rate = demand_rate + + assert actuator_range[0] <= actuator_range[1], ( + "actuator_range[0] must be <= actuator_range[1]." + ) + self.actuator_range = actuator_range + + assert actuator_rate_limit is None or actuator_rate_limit >= 0, ( + "actuator_rate_limit must be non-negative or None." + ) + self.actuator_rate_limit = actuator_rate_limit + + self.clamp = clamp + + assert actuator_time_constant is None or actuator_time_constant >= 0, ( + "actuator_time_constant must be non-negative or None." + ) + self.actuator_time_constant = actuator_time_constant + self._update_iir_coefficients() + + self.actuator_initial_output = actuator_initial_output + self._actuator_output = actuator_initial_output + + def _update_iir_coefficients(self): + """Updates the IIR filter coefficient based on time constant and + demand rate. Uses first-order discrete-time system: + y[n] = alpha * u[n] + (1 - alpha) * y[n-1] + where alpha = Ts / (tau + Ts) + """ + + if self.actuator_time_constant is not None and self.actuator_time_constant > 0: + if self.demand_rate is not None: + demand_period = 1.0 / self.demand_rate + self._alpha = demand_period / ( + self.actuator_time_constant + demand_period + ) + else: + print( + f"Warning: Actuator time constant currently only implemented on discrete controllers. '{self.name}' dynamics not applied." + ) + self._alpha = 1.0 # No filtering, direct pass-through + else: + self._alpha = 1.0 # No filtering, direct pass-through + + @property + def actuator_output(self): + return self._actuator_output + + @actuator_output.setter + def actuator_output(self, value): + """Sets the actuator output with optional clamping or warning. + + Parameters + ---------- + value : float + Desired actuator output. + + Returns + ------- + None + """ + # Apply first-order IIR actuator dynamics + value = self._alpha * value + (1 - self._alpha) * self._actuator_output + + # Apply rate limit if specified + if self.actuator_rate_limit is not None: + if self.demand_rate is not None: + max_change = self.actuator_rate_limit / self.demand_rate + change = value - self._actuator_output + if abs(change) > max_change: + value = self._actuator_output + np.sign(change) * max_change + print( + f"Warning: Actuator '{self.name}' output change {change:.3f} exceeds rate limit of {max_change:.3f} per time step." + ) + else: + print( + f"Warning: Actuator rate limit currently only implemented for discrete controllers. '{self.name}' rate limit not applied." + ) + + # Apply range limits if specified + if self.clamp: + value = np.clip(value, self.actuator_range[0], self.actuator_range[1]) + else: + if value < self.actuator_range[0] or value > self.actuator_range[1]: + print( + f"Warning: Actuator '{self.name}' output {value:.3f} exceeds range limits {self.actuator_range}." + ) + + self._actuator_output = value + + def _reset(self): + """Resets the actuator to its initial state. This method + is called at the beginning of each simulation to ensure the actuator + is in the correct state.""" + self._actuator_output = self.actuator_initial_output + + @abstractmethod + def info(self): + """Prints summarized information of the actuator. + + Returns + ------- + None + """ + + @abstractmethod + def all_info(self): + """Prints all information of the actuator. + + Returns + ------- + None + """ diff --git a/rocketpy/rocket/actuator/roll.py b/rocketpy/rocket/actuator/roll.py index 0cca1429a..95aa4b742 100644 --- a/rocketpy/rocket/actuator/roll.py +++ b/rocketpy/rocket/actuator/roll.py @@ -1,145 +1,101 @@ -import warnings +from rocketpy.prints.roll_actuator_prints import _RollActuatorPrints -import numpy as np +from .actuator import Actuator -from rocketpy.prints.roll_control_prints import _RollControlPrints +class RollActuator(Actuator): + """Roll actuator class as a controllable component. Inherits from Actuator. -class RollActuator: - """Roll actuator class as a controllable component. - - This class represents a roll control system that allows the application - of roll torque around the rocket's Z-axis. Ideal roll torque is assumed. + This class represents a roll actuator that applies roll torque around the rocket's Z-axis. + Magic/hand-of-god roll torque is assumed. The roll torque is positive for counter-clockwise rotation when + viewed from the nose of the rocket. + This actuator is typically controlled by a controller function similar to air brakes + and is used by ``Flight`` to model roll control system. Attributes ---------- - RollControl.roll_torque : float - Current roll torque magnitude in N·m (Newton-meters). + RollActuator.name : str + Name of the roll actuator. + RollActuator.demand_rate : float + Demand rate of the roll control in Hz. None indicates a continuous-time actuator. + RollActuator.actuator_range : float + Range of the roll control in N·m. + RollActuator.actuator_rate_limit : float + Rate limit of the roll control in N·m/s. The roll torque change is limited to this rate. + RollActuator.clamp : bool, optional + If True, roll torque is clamped to actuator_range. + If False, a warning is issued when roll torque exceeds the range. + RollActuator.actuator_time_constant : float + Time constant for the roll torque actuator dynamics (first-order IIR filter) in seconds. + RollActuator.actuator_initial_output : float + Initial roll torque in N·m. + RollActuator.roll_torque : float + Current roll torque output magnitude in N·m (Newton-meters). Positive values indicate counter-clockwise rotation when viewed from the nose of the rocket. - RollControl.max_roll_torque : float - Maximum roll torque magnitude in N·m. The roll torque is clamped - to this value if clamp is True. - RollControl.clamp : bool, optional - If True, roll torque is clamped to [-max_roll_torque, max_roll_torque]. - If False, a warning is issued when roll torque exceeds the max value. - RollControl.name : str - Name of the roll control system. """ def __init__( self, - sampling_rate=100, + name="Roll Control", + demand_rate=100, max_roll_torque=0, - torque_rate_limit=0, + torque_rate_limit=None, clamp=True, - roll_torque=0.0, - name="Roll Control", - actuator_tau=None, + initial_roll_torque=0.0, + roll_torque_time_constant=None, ): """Initializes the RollControl class. Parameters ---------- - sampling_rate : int, optional - Sampling rate of the roll control system in Hz. Default is 100 Hz. + name : str, optional + Name of the roll actuator. Default is "Roll Control". + demand_rate : int, optional + Demand rate of the roll actuator in Hz. Default is 100 Hz. + None indicates a continuous-time actuator. max_roll_torque : float, int Maximum roll torque magnitude in N·m. Must be non-negative. Default is 0 (no roll control). torque_rate_limit : float, int Maximum roll torque rate in N·m/s. Roll torque is limited to this - rate. Must be non-negative. Default is 0 (no torque change). + rate. Must be non-negative. Default is None (no limit). demand_rate must be specified if torque_rate_limit is not None. clamp : bool, optional If True, the simulation will clamp roll torque to the range [-max_roll_torque, max_roll_torque] if it exceeds this range. If False, the simulation will issue a warning if roll torque exceeds the maximum value. Default is True. - roll_torque : float, optional + initial_roll_torque : float, optional Initial roll torque in N·m. Default is 0.0 (no torque). - name : str, optional - Name of the roll control system. Default is "Roll Control". - actuator_tau : float, optional - Time constant for the torque actuator dynamics (first-order IIR + roll_torque_time_constant : float, optional + Time constant for the roll torque actuator dynamics (first-order IIR filter) in seconds. If None, no actuator dynamics are applied. - Must be non-negative. Default is None. + Must be non-negative. Default is None. demand_rate must be specified if roll_torque_time_constant is not None. Returns ------- None """ - self.name = name - self.sampling_rate = sampling_rate - assert max_roll_torque >= 0, "max_roll_torque must be non-negative." - self.max_roll_torque = max_roll_torque - assert torque_rate_limit >= 0, "torque_rate_limit must be non-negative." - self.torque_rate_limit = torque_rate_limit - self.clamp = clamp - # Actuator dynamics parameters - if actuator_tau is not None: - assert actuator_tau >= 0, "actuator_tau must be non-negative." - self.actuator_tau = actuator_tau - # Compute IIR filter coefficients (first-order system) - self._update_iir_coefficients() - self.initial_roll_torque = roll_torque - self.roll_torque_prev = roll_torque - self.roll_torque_filtered = roll_torque - self.roll_torque = roll_torque - self.prints = _RollControlPrints(self) - - def _update_iir_coefficients(self): - """Updates the IIR filter coefficient based on time constant and - sampling rate. Uses first-order discrete-time system: - y[n] = alpha * u[n] + (1 - alpha) * y[n-1] - where alpha = Ts / (tau + Ts) - """ - sampling_period = 1.0 / self.sampling_rate - if self.actuator_tau is not None and self.actuator_tau > 0: - self._alpha = sampling_period / (self.actuator_tau + sampling_period) - else: - self._alpha = 1.0 # No filtering, direct pass-through + super().__init__( + name=name, + demand_rate=demand_rate, + actuator_range=(-max_roll_torque, max_roll_torque), + actuator_rate_limit=torque_rate_limit, + clamp=clamp, + actuator_initial_output=initial_roll_torque, + actuator_time_constant=roll_torque_time_constant, + ) + self.prints = _RollActuatorPrints(self) @property def roll_torque(self): """Returns the current roll torque in N·m.""" - return self._roll_torque + return self._actuator_output @roll_torque.setter def roll_torque(self, value): - """Sets the roll torque with optional clamping or warning. - - Parameters - ---------- - value : float - Roll torque in N·m. - """ - if abs(value) > self.max_roll_torque: - if self.clamp: - value = np.clip(value, -self.max_roll_torque, self.max_roll_torque) - else: - warnings.warn( - f"Roll torque of {self.name} is {value:.4f} N·m, " - f"which exceeds the maximum of {self.max_roll_torque:.4f} N·m.", - UserWarning, - ) - # Limit the roll torque rate - max_torque_change = self.torque_rate_limit / self.sampling_rate - torque_change = value - self.roll_torque_prev - if abs(torque_change) > max_torque_change: - value = self.roll_torque_prev + np.sign(torque_change) * max_torque_change - self.roll_torque_prev = value - # Apply first-order IIR actuator dynamics - self.roll_torque_filtered = ( - self._alpha * value + (1 - self._alpha) * self.roll_torque_filtered - ) - self._roll_torque = self.roll_torque_filtered - - def _reset(self): - """Resets the roll control system to its initial state. This method - is called at the beginning of each simulation to ensure the roll - control system is in the correct state.""" - self.roll_torque_prev = self.initial_roll_torque - self.roll_torque_filtered = self.initial_roll_torque - self.roll_torque = self.initial_roll_torque + """Sets the roll torque in N·m.""" + self._actuator_output = value def info(self): """Prints summarized information of the roll control system. @@ -161,23 +117,23 @@ def all_info(self): def to_dict(self, **kwargs): # pylint: disable=unused-argument return { - "sampling_rate": self.sampling_rate, - "max_roll_torque": self.max_roll_torque, - "torque_rate_limit": self.torque_rate_limit, - "clamp": self.clamp, - "roll_torque": self.initial_roll_torque, - "actuator_tau": self.actuator_tau, "name": self.name, + "demand_rate": self.demand_rate, + "max_roll_torque": self.actuator_range[1], + "torque_rate_limit": self.actuator_rate_limit, + "clamp": self.clamp, + "initial_roll_torque": self.actuator_initial_output, + "roll_torque_time_constant": self.actuator_time_constant, } @classmethod def from_dict(cls, data): return cls( - sampling_rate=data.get("sampling_rate"), + name=data.get("name"), + demand_rate=data.get("demand_rate"), max_roll_torque=data.get("max_roll_torque"), torque_rate_limit=data.get("torque_rate_limit"), clamp=data.get("clamp"), - roll_torque=data.get("roll_torque"), - actuator_tau=data.get("actuator_tau"), - name=data.get("name"), + initial_roll_torque=data.get("initial_roll_torque"), + roll_torque_time_constant=data.get("roll_torque_time_constant"), ) diff --git a/rocketpy/rocket/actuator/throttle.py b/rocketpy/rocket/actuator/throttle.py index 0b4c8ddb1..3ad7115f4 100644 --- a/rocketpy/rocket/actuator/throttle.py +++ b/rocketpy/rocket/actuator/throttle.py @@ -1,147 +1,103 @@ -import warnings +from rocketpy.prints.throttle_actuator_prints import _ThrottleActuatorPrints -import numpy as np +from .actuator import Actuator -from rocketpy.prints.throttle_control_prints import _ThrottleControlPrints +class ThrottleActuator(Actuator): + """Throttle actuator class as a controllable component. Inherits from Actuator. -class ThrottleActuator: - """Throttle actuator class as a controllable component. + This class represents a throttle actuator that applies throttle around the rocket's engine. + The throttle is typically controlled by a controller function similar to air brakes + and is used by ``Flight`` to model throttle control system. - This class represents a throttle control system that allows the application - of throttle around the rocket's engine. Ideal throttle is assumed. + This class represents a throttle actuator that throttles the rocket's engine. + The throttle is the fraction of the maximum thrust produced by the engine, ranging from 0 (no thrust) to 1 (full thrust). + This actuator is typically controlled by a controller function similar to air brakes + and is used by ``Flight`` to model throttle control system. Attributes ---------- - ThrottleControl.throttle : float - Current throttle magnitude as a fraction of maximum thrust. - Positive values indicate forward thrust. - ThrottleControl.throttle_range : tuple of float - Tuple representing the minimum and maximum throttle values. - The throttle is clamped to this range if clamp is True. - ThrottleControl.clamp : bool, optional - If True, throttle is clamped to throttle_range. + ThrottleActuator.name : str + Name of the throttle actuator. + ThrottleActuator.demand_rate : float + Demand rate of the throttle actuator in Hz. None indicates a continuous-time actuator. + ThrottleActuator.actuator_range : float + Range of the throttle actuator. + ThrottleActuator.actuator_rate_limit : float + Rate limit of the throttle actuator in 1/s. The throttle change is limited to this rate. + ThrottleActuator.clamp : bool, optional + If True, throttle is clamped to actuator_range. If False, a warning is issued when throttle exceeds the range. - ThrottleControl.name : str - Name of the throttle control system. + ThrottleActuator.actuator_time_constant : float + Time constant for the throttle actuator dynamics (first-order IIR filter) in seconds. + ThrottleActuator.actuator_initial_output : float + Initial throttle value. + ThrottleActuator.throttle : float + Current throttle value. The throttle is the fraction of the maximum thrust produced by the engine + ranging from 0 (no thrust) to 1 (full thrust). """ def __init__( self, - sampling_rate=100, - throttle_range=(0.0, 1.0), - throttle_rate_limit=0, - clamp=True, - throttle=1.0, - actuator_tau=None, name="Throttle Control", + demand_rate=100, + max_throttle=1, + throttle_rate_limit=None, + clamp=True, + initial_throttle=0.0, + throttle_time_constant=None, ): - """Initializes the ThrottleControl class. + """Initializes the ThrottleActuator class. Parameters ---------- - sampling_rate : int, optional - Sampling rate of the throttle control system in Hz. Default is 100 Hz. - throttle_range : tuple of float, optional - Tuple representing the minimum and maximum throttle values. - The throttle is clamped to this range if clamp is True. - Default is (0.0, 1.0). + name : str, optional + Name of the throttle actuator. Default is "Throttle Control". + demand_rate : int, optional + Demand rate of the throttle actuator in Hz. Default is 100 Hz. + None indicates a continuous-time actuator. + max_throttle : float, int + Maximum throttle value. Must be non-negative. + Default is 1 (full throttle). throttle_rate_limit : float, int - Maximum throttle rate in units per second. Throttle is limited to this - rate. Must be non-negative. Default is 0 (no throttle change). + Maximum throttle rate in 1/s. Throttle is limited to this + rate. Must be non-negative. Default is None (no limit). demand_rate must be specified if throttle_rate_limit is not None. clamp : bool, optional If True, the simulation will clamp throttle to the range - [throttle_range[0], throttle_range[1]] if it exceeds this range. + [0, max_throttle] if it exceeds this range. If False, the simulation will issue a warning if throttle - exceeds the range. Default is True. - throttle : float, optional - Initial throttle value. Default is 1.0 (full throttle). - actuator_tau : float, optional - Time constant for the actuator dynamics (first-order IIR filter) - in seconds. If None, no actuator dynamics are applied. - Must be non-negative. Default is None. - name : str, optional - Name of the throttle control system. Default is "Throttle Control". + exceeds the maximum value. Default is True. + initial_throttle : float, optional + Initial throttle value. Default is 0.0 (no thrust). + throttle_time_constant : float, optional + Time constant for the throttle actuator dynamics (first-order IIR + filter) in seconds. If None, no actuator dynamics are applied. + Must be non-negative. Default is None. demand_rate must be specified if throttle_time_constant is not None. Returns ------- None """ - self.name = name - self.sampling_rate = sampling_rate - assert throttle_range[0] <= throttle_range[1], ( - "throttle_range[0] must be <= throttle_range[1]" + super().__init__( + name=name, + demand_rate=demand_rate, + actuator_range=(0, max_throttle), + actuator_rate_limit=throttle_rate_limit, + clamp=clamp, + actuator_initial_output=initial_throttle, + actuator_time_constant=throttle_time_constant, ) - self.throttle_range = throttle_range - assert throttle_rate_limit >= 0, "throttle_rate_limit must be non-negative." - self.throttle_rate_limit = throttle_rate_limit - self.clamp = clamp - # Actuator dynamics parameters - if actuator_tau is not None: - assert actuator_tau >= 0, "actuator_tau must be non-negative." - self.actuator_tau = actuator_tau - # Compute IIR filter coefficients (first-order system) - self._update_iir_coefficients() - self.initial_throttle = throttle - self.throttle_prev = throttle - self.throttle_filtered = throttle - self.throttle = throttle - self.prints = _ThrottleControlPrints(self) - - def _update_iir_coefficients(self): - """Updates the IIR filter coefficient based on time constant and - sampling rate. Uses first-order discrete-time system: - y[n] = alpha * u[n] + (1 - alpha) * y[n-1] - where alpha = Ts / (tau + Ts) - """ - sampling_period = 1.0 / self.sampling_rate - if self.actuator_tau is not None and self.actuator_tau > 0: - self._alpha = sampling_period / (self.actuator_tau + sampling_period) - else: - self._alpha = 1.0 # No filtering, direct pass-through + self.prints = _ThrottleActuatorPrints(self) @property def throttle(self): - return self._throttle + """Returns the current throttle value.""" + return self._actuator_output @throttle.setter def throttle(self, value): - """Sets the throttle with optional clamping or warning. - - Parameters - ---------- - value : float - Throttle value as a fraction of maximum thrust. - """ - if value < self.throttle_range[0] or value > self.throttle_range[1]: - if self.clamp: - value = np.clip(value, self.throttle_range[0], self.throttle_range[1]) - else: - warnings.warn( - f"Throttle of {self.name} is {value:.4f}, " - f"which exceeds bounds " - f"[{self.throttle_range[0]:.4f}, {self.throttle_range[1]:.4f}].", - UserWarning, - ) - # Limit the throttle rate - max_throttle_change = self.throttle_rate_limit / self.sampling_rate - throttle_change = value - self.throttle_prev - if abs(throttle_change) > max_throttle_change: - value = self.throttle_prev + np.sign(throttle_change) * max_throttle_change - self.throttle_prev = value - # Apply first-order IIR actuator dynamics - self.throttle_filtered = ( - self._alpha * value + (1 - self._alpha) * self.throttle_filtered - ) - self._throttle = self.throttle_filtered - - def _reset(self): - """Resets the throttle control system to its initial state. This method - is called at the beginning of each simulation to ensure the throttle - control system is in the correct state.""" - self.throttle_prev = self.initial_throttle - self.throttle_filtered = self.initial_throttle - self.throttle = self.initial_throttle + """Sets the throttle value.""" + self._actuator_output = value def info(self): """Prints summarized information of the throttle control system. @@ -152,25 +108,34 @@ def info(self): """ self.prints.basics() + def all_info(self): + """Prints all information of the throttle control system. + + Returns + ------- + None + """ + self.info() + def to_dict(self, **kwargs): # pylint: disable=unused-argument return { - "sampling_rate": self.sampling_rate, - "throttle_range": self.throttle_range, - "throttle_rate_limit": self.throttle_rate_limit, - "clamp": self.clamp, - "throttle": self.throttle, - "actuator_tau": self.actuator_tau, "name": self.name, + "demand_rate": self.demand_rate, + "max_throttle": self.actuator_range[1], + "throttle_rate_limit": self.actuator_rate_limit, + "clamp": self.clamp, + "initial_throttle": self.actuator_initial_output, + "throttle_time_constant": self.actuator_time_constant, } @classmethod def from_dict(cls, data): return cls( - sampling_rate=data.get("sampling_rate"), - throttle_range=data.get("throttle_range"), + name=data.get("name"), + demand_rate=data.get("demand_rate"), + max_throttle=data.get("max_throttle"), throttle_rate_limit=data.get("throttle_rate_limit"), clamp=data.get("clamp"), - throttle=data.get("throttle"), - actuator_tau=data.get("actuator_tau"), - name=data.get("name"), + initial_throttle=data.get("initial_throttle"), + throttle_time_constant=data.get("throttle_time_constant"), ) diff --git a/rocketpy/rocket/actuator/thrust_vector.py b/rocketpy/rocket/actuator/thrust_vector.py index c03630c24..992913a5d 100644 --- a/rocketpy/rocket/actuator/thrust_vector.py +++ b/rocketpy/rocket/actuator/thrust_vector.py @@ -1,229 +1,113 @@ -import warnings +from rocketpy.prints.thrust_vector_actuator_prints import _ThrustVectorActuatorPrints -import numpy as np +from .actuator import Actuator -from rocketpy.prints.tvc_prints import _TVCPrints +class ThrustVectorActuator(Actuator): + """Thrust vector actuator class as a controllable component. Inherits from Actuator. -class ThrustVectorActuator: - """Thrust vector acuatot class as a controllable component. - - This class represents a thrust vector control system that allows deflection - of the thrust vector through gimbal angles. TVC is typically controlled - by a controller function similar to air brakes and is used by ``Flight`` - to model thrust vectoring behavior. + This class represents a thrust vector actuator that deflects the direction + of the thrust vector through gimbal angle. + This actuator is typically controlled by a controller function similar to air brakes + and is used by ``Flight`` to model thrust vector control (TVC). Attributes ---------- - TVC.gimbal_angle_x : float - Current gimbal angle around the x-axis (pitch), in degrees. - Positive values provides positive M1 (pitch moment). - TVC.gimbal_angle_y : float - Current gimbal angle around the y-axis (yaw), in degrees. - Positive values provides positive M2 (yaw moment). - TVC.gimbal_range : float - Maximum gimbal angle magnitude in degrees. Both x and y gimbal angles - are clamped to this value if clamp is True. - TVC.clamp : bool, optional - If True, gimbal angles are clamped to [-gimbal_range, gimbal_range]. - If False, a warning is issued when gimbal angles exceed the max value. - TVC.name : str - Name of the TVC system. + ThrustVectorActuator.name : str + Name of the thrust vector actuator. + ThrustVectorActuator.demand_rate : float + Demand rate of the thrust vector actuator in Hz. None indicates a continuous-time actuator. + ThrustVectorActuator.actuator_range : float + Range of the thrust vector actuator in deg. + ThrustVectorActuator.actuator_rate_limit : float + Rate limit of the thrust vector actuator in deg/sec. The thrust vector change is limited to this rate. + ThrustVectorActuator.clamp : bool, optional + If True, thrust gimbal angle is clamped to actuator_range. + If False, a warning is issued when thrust vector exceeds the range. + ThrustVectorActuator.actuator_time_constant : float + Time constant for the thrust vector actuator dynamics (first-order IIR filter) in seconds. + ThrustVectorActuator.actuator_initial_output : float + Initial thrust vector gimbal angle in deg. + ThrustVectorActuator._actuator_output : float + Current thrust vector gimbal angle in deg. + """ def __init__( self, - sampling_rate=100, - gimbal_range=0, - gimbal_rate_limit=0, + name="Thrust Vector Control", + demand_rate=100, + max_gimbal_angle=10, + gimbal_rate_limit=None, clamp=True, - gimbal_angle_x=0.0, - gimbal_angle_y=0.0, - actuator_tau_x=None, - actuator_tau_y=None, - name="TVC", + initial_gimbal_angle=0.0, + gimbal_time_constant=None, ): - """Initializes the TVC class. + """Initializes the thrust vector actuator class. Parameters ---------- - sampling_rate : int, optional - Sampling rate of the TVC controller in Hz. Default is 100 Hz. - gimbal_range : int, float - Maximum gimbal angle magnitude in degrees. Both x and y gimbal - angles are clamped to this value if clamp is True. Must be - non-negative. Default is 0 (no deflection). - gimbal_rate_limit : int, float, optional - Maximum gimbal rate in degrees per second. Both x and y gimbal - rates are limited to this value. Must be non-negative. - Default is 0 (no movement). + name : str, optional + Name of the thrust vector actuator. Default is "Thrust Vector Control". + demand_rate : int, optional + Demand rate of the thrust vector actuator in Hz. Default is 100 Hz. + None indicates a continuous-time actuator. + max_gimbal_angle : float, int + Maximum gimbal angle in deg. Must be non-negative. + Default is 10 deg. + gimbal_rate_limit : float, int + Maximum gimbal rate in deg/sec. Must be non-negative. + Default is None (no limit). demand_rate must be specified if gimbal_rate_limit is not None. clamp : bool, optional - If True, the simulation will clamp gimbal angles to the range - [-gimbal_range, gimbal_range] if they exceed this range. - If False, the simulation will issue a warning if gimbal angles - exceed the maximum value. Default is True. - gimbal_angle_x : float, optional - Initial gimbal angle around the x-axis (pitch) in degrees. - Default is 0.0 (no deflection). - gimbal_angle_y : float, optional - Initial gimbal angle around the y-axis (yaw) in degrees. - Default is 0.0 (no deflection). - actuator_tau_x : float, optional - Time constant for the x-axis actuator dynamics (first-order IIR + If True, the simulation will clamp gimbal angle to the range + [-max_gimbal_angle, max_gimbal_angle] if it exceeds this range. + If False, the simulation will issue a warning if gimbal angle + exceeds the maximum value. Default is True. + initial_gimbal_angle : float, optional + Initial gimbal angle in deg. Default is 0.0 (no gimbal). + roll_torque_time_constant : float, optional + Time constant for the roll torque actuator dynamics (first-order IIR filter) in seconds. If None, no actuator dynamics are applied. - Must be non-negative. Default is None. - actuator_tau_y : float, optional - Time constant for the y-axis actuator dynamics (first-order IIR - filter) in seconds. If None, no actuator dynamics are applied. - Must be non-negative. Default is None. - name : str, optional - Name of the TVC system. Default is "TVC". + Must be non-negative. Default is None. demand_rate must be specified if roll_torque_time_constant is not None. + Returns ------- None """ - self.name = name - self.sampling_rate = sampling_rate - assert gimbal_range >= 0, "gimbal_range must be non-negative." - self.gimbal_range = gimbal_range - assert gimbal_rate_limit >= 0, "gimbal_rate_limit must be non-negative." - self.gimbal_rate_limit = gimbal_rate_limit - self.clamp = clamp - # Actuator dynamics parameters - if actuator_tau_x is not None: - assert actuator_tau_x >= 0, "actuator_tau_x must be non-negative." - if actuator_tau_y is not None: - assert actuator_tau_y >= 0, "actuator_tau_y must be non-negative." - self.actuator_tau_x = actuator_tau_x - self.actuator_tau_y = actuator_tau_y - # Compute IIR filter coefficients (first-order system) - self._update_iir_coefficients() - self.initial_gimbal_angle_x = gimbal_angle_x - self.initial_gimbal_angle_y = gimbal_angle_y - self.gimbal_angle_x_prev = gimbal_angle_x - self.gimbal_angle_y_prev = gimbal_angle_y - self.gimbal_angle_x_filtered = gimbal_angle_x - self.gimbal_angle_y_filtered = gimbal_angle_y - self.gimbal_angle_x = gimbal_angle_x - self.gimbal_angle_y = gimbal_angle_y - self.prints = _TVCPrints(self) - - def _update_iir_coefficients(self): - """Updates the IIR filter coefficients based on time constants and - sampling rate. Uses first-order discrete-time system: - y[n] = alpha * u[n] + (1 - alpha) * y[n-1] - where alpha = Ts / (tau + Ts) - """ - sampling_period = 1.0 / self.sampling_rate - # X-axis coefficients - if self.actuator_tau_x is not None and self.actuator_tau_x > 0: - self._alpha_x = sampling_period / (self.actuator_tau_x + sampling_period) - else: - self._alpha_x = 1.0 # No filtering, direct pass-through - # Y-axis coefficients - if self.actuator_tau_y is not None and self.actuator_tau_y > 0: - self._alpha_y = sampling_period / (self.actuator_tau_y + sampling_period) - else: - self._alpha_y = 1.0 # No filtering, direct pass-through - - @property - def gimbal_angle_x(self): - """Returns the current gimbal angle around the x-axis (pitch).""" - return self._gimbal_angle_x - @gimbal_angle_x.setter - def gimbal_angle_x(self, value): - # Check if deployment level is within bounds and warn user if not - if abs(value) > self.gimbal_range: - if self.clamp: - value = np.clip(value, -self.gimbal_range, self.gimbal_range) - else: - warnings.warn( - f"Gimbal angle x of {self.name} is {value:.4f} deg, " - f"which exceeds the maximum of {self.gimbal_range:.4f} deg.", - UserWarning, - ) - # Limit the gimbal rate - max_angle_change = self.gimbal_rate_limit / self.sampling_rate - angle_change = value - self.gimbal_angle_x_prev - if abs(angle_change) > max_angle_change: - value = self.gimbal_angle_x_prev + np.sign(angle_change) * max_angle_change - self.gimbal_angle_x_prev = value - # Apply first-order IIR actuator dynamics - self.gimbal_angle_x_filtered = ( - self._alpha_x * value + (1 - self._alpha_x) * self.gimbal_angle_x_filtered + super().__init__( + name=name, + demand_rate=demand_rate, + actuator_range=(-max_gimbal_angle, max_gimbal_angle), + actuator_rate_limit=gimbal_rate_limit, + clamp=clamp, + actuator_initial_output=initial_gimbal_angle, + actuator_time_constant=gimbal_time_constant, ) - self._gimbal_angle_x = self.gimbal_angle_x_filtered + self.prints = _ThrustVectorActuatorPrints(self) @property - def gimbal_angle_y(self): - """Returns the current gimbal angle around the y-axis (yaw).""" - return self._gimbal_angle_y - - @gimbal_angle_y.setter - def gimbal_angle_y(self, value): - # Check if deployment level is within bounds and warn user if not - if abs(value) > self.gimbal_range: - if self.clamp: - value = np.clip(value, -self.gimbal_range, self.gimbal_range) - else: - warnings.warn( - f"Gimbal angle y of {self.name} is {value:.4f} deg, " - f"which exceeds the maximum of {self.gimbal_range:.4f} deg.", - UserWarning, - ) - # Limit the gimbal rate - max_angle_change = self.gimbal_rate_limit / self.sampling_rate - angle_change = value - self.gimbal_angle_y_prev - if abs(angle_change) > max_angle_change: - value = self.gimbal_angle_y_prev + np.sign(angle_change) * max_angle_change - self.gimbal_angle_y_prev = value - # Apply first-order IIR actuator dynamics - self.gimbal_angle_y_filtered = ( - self._alpha_y * value + (1 - self._alpha_y) * self.gimbal_angle_y_filtered - ) - self._gimbal_angle_y = self.gimbal_angle_y_filtered - - @property - def gimbal_angles(self): - """Returns a tuple of the current gimbal angles (x, y) in degrees.""" - return (self.gimbal_angle_x, self.gimbal_angle_y) - - @gimbal_angles.setter - def gimbal_angles(self, value): - """Sets both gimbal angles from a tuple. + def gimbal_angle(self): + """Returns the current gimbal angle in deg.""" + return self._actuator_output - Parameters - ---------- - value : tuple - Tuple of (gimbal_angle_x, gimbal_angle_y) in degrees. - """ - self.gimbal_angle_x = value[0] - self.gimbal_angle_y = value[1] - - def _reset(self): - """Resets the TVC system to its initial state. This method is called - at the beginning of each simulation to ensure the TVC system is in - the correct state.""" - self.gimbal_angle_x_prev = self.initial_gimbal_angle_x - self.gimbal_angle_y_prev = self.initial_gimbal_angle_y - self.gimbal_angle_x_filtered = self.initial_gimbal_angle_x - self.gimbal_angle_y_filtered = self.initial_gimbal_angle_y - self.gimbal_angle_x = self.initial_gimbal_angle_x - self.gimbal_angle_y = self.initial_gimbal_angle_y + @gimbal_angle.setter + def gimbal_angle(self, value): + """Sets the gimbal angle in deg.""" + self._actuator_output = value def info(self): - """Prints summarized information of the TVC system. + """Prints summarized information of the thrust vector actuator. Returns ------- None """ - self.prints.geometry() + self.prints.basics() def all_info(self): - """Prints all information of the TVC system. + """Prints all information of the thrust vector actuator. Returns ------- @@ -233,27 +117,153 @@ def all_info(self): def to_dict(self, **kwargs): # pylint: disable=unused-argument return { - "sampling_rate": self.sampling_rate, - "gimbal_range": self.gimbal_range, - "gimbal_rate_limit": self.gimbal_rate_limit, - "clamp": self.clamp, - "gimbal_angle_x": self.initial_gimbal_angle_x, - "gimbal_angle_y": self.initial_gimbal_angle_y, - "actuator_tau_x": self.actuator_tau_x, - "actuator_tau_y": self.actuator_tau_y, "name": self.name, + "demand_rate": self.demand_rate, + "max_gimbal_angle": self.actuator_range[1], + "gimbal_rate_limit": self.actuator_rate_limit, + "clamp": self.clamp, + "initial_gimbal_angle": self.actuator_initial_output, + "gimbal_time_constant": self.actuator_time_constant, } @classmethod def from_dict(cls, data): return cls( - sampling_rate=data.get("sampling_rate"), - gimbal_range=data.get("gimbal_range"), + name=data.get("name"), + demand_rate=data.get("demand_rate"), + max_gimbal_angle=data.get("max_gimbal_angle"), gimbal_rate_limit=data.get("gimbal_rate_limit"), clamp=data.get("clamp"), - gimbal_angle_x=data.get("gimbal_angle_x"), - gimbal_angle_y=data.get("gimbal_angle_y"), - actuator_tau_x=data.get("actuator_tau_x"), - actuator_tau_y=data.get("actuator_tau_y"), - name=data.get("name"), + initial_gimbal_angle=data.get("initial_gimbal_angle"), + gimbal_time_constant=data.get("gimbal_time_constant"), + ) + + +class ThrustVectorActuator2D: + """Dual-axis thrust vector actuator class as a controllable component. + + This class represents dual-axis thrust vector actuator that deflects the direction + of the thrust vector through dual-axis gimbal angles. + This actuator is typically controlled by a controller function similar to air brakes + and is used by ``Flight`` to model thrust vector control (TVC). + + Attributes + ---------- + ThrustVectorActuator2D.name : str + Name of the dual-axis thrust vector actuator. + ThrustVectorActuator2D.demand_rate : float + Demand rate of the dual-axis thrust vector actuator in Hz. None indicates a continuous-time actuator. + ThrustVectorActuator2D.actuator_range : float + Range of the dual-axis thrust vector actuator in deg. + ThrustVectorActuator2D.actuator_rate_limit : float + Rate limit of the dual-axis thrust vector actuator in deg/sec. The thrust vector change is limited to this rate. + ThrustVectorActuator2D.clamp : bool, optional + If True, thrust vector gimbal angles are clamped to actuator_range. + If False, a warning is issued when thrust vector exceeds the max value. + ThrustVectorActuator2D.actuator_time_constant : float + Time constant for the thrust vector actuator dynamics (first-order IIR filter) in seconds. + ThrustVectorActuator2D.actuator_initial_output : float + Initial thrust gimbal angles in deg. + ThrustVectorActuator2D._actuator_output : float + Current thrust vector gimble angles in deg. + + """ + + def __init__( + self, + name="Thrust Vector Control", + demand_rate=100, + max_gimbal_angle=10, + gimbal_rate_limit=None, + clamp=True, + initial_gimbal_angle=0.0, + gimbal_time_constant=None, + ): + """Initializes the dual-axis thrust vector actuator class. + + Parameters + ---------- + name : str, optional + Name of the dual-axis thrust vector actuator. Default is "Thrust Vector Control". + demand_rate : int, optional + Demand rate of the dual-axis thrust vector actuator in Hz. Default is 100 Hz. + None indicates a continuous-time actuator. + max_gimbal_angle : float, int + Maximum gimbal angle in deg. Must be non-negative. + Default is 10 deg. + gimbal_rate_limit : float, int + Maximum gimbal rate in deg/sec. Must be non-negative. + Default is None (no limit). demand_rate must be specified if gimbal_rate_limit is not None. + clamp : bool, optional + If True, the simulation will clamp gimbal angles to the range + [-max_gimbal_angle, max_gimbal_angle] if it exceeds this range. + If False, the simulation will issue a warning if gimbal angle + exceeds the maximum value. Default is True. + initial_gimbal_angle : float, optional + Initial gimbal angle in deg. Default is 0.0 (no gimbal). + roll_torque_time_constant : float, optional + Time constant for the roll torque actuator dynamics (first-order IIR + filter) in seconds. If None, no actuator dynamics are applied. + Must be non-negative. Default is None. demand_rate must be specified if roll_torque_time_constant is not None. + + + Returns + ------- + None + """ + + self.x = ThrustVectorActuator( + name=name + " X-axis", + demand_rate=demand_rate, + max_gimbal_angle=max_gimbal_angle, + gimbal_rate_limit=gimbal_rate_limit, + clamp=clamp, + initial_gimbal_angle=initial_gimbal_angle, + gimbal_time_constant=gimbal_time_constant, + ) + self.y = ThrustVectorActuator( + name=name + " Y-axis", + demand_rate=demand_rate, + max_gimbal_angle=max_gimbal_angle, + gimbal_rate_limit=gimbal_rate_limit, + clamp=clamp, + initial_gimbal_angle=initial_gimbal_angle, + gimbal_time_constant=gimbal_time_constant, ) + + @property + def gimbal_angle_x(self): + """Returns the current gimbal angle around the y-axis (yaw).""" + return self.x.gimbal_angle + + @gimbal_angle_x.setter + def gimbal_angle_x(self, value): + """Sets the gimbal angle in deg.""" + self.x.gimbal_angle = value + + @property + def gimbal_angle_y(self): + """Returns the current gimbal angle around the y-axis (yaw).""" + return self.y.gimbal_angle + + @gimbal_angle_y.setter + def gimbal_angle_y(self, value): + """Sets the gimbal angle in deg.""" + self.y.gimbal_angle = value + + @property + def gimbal_angles(self): + """Returns a tuple of the current gimbal angles (x, y) in degrees.""" + return (self.gimbal_angle_x, self.gimbal_angle_y) + + @gimbal_angles.setter + def gimbal_angles(self, value): + """Sets both gimbal angles from a tuple. + + Parameters + ---------- + value : tuple + Tuple of (gimbal_angle_x, gimbal_angle_y) in degrees. + """ + self.gimbal_angle_x = value[0] + self.gimbal_angle_y = value[1] diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 6f974edfa..f92080be5 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -12,6 +12,9 @@ from rocketpy.motors.empty_motor import EmptyMotor from rocketpy.plots.rocket_plots import _RocketPlots from rocketpy.prints.rocket_prints import _RocketPrints +from rocketpy.rocket.actuator.roll import RollActuator +from rocketpy.rocket.actuator.throttle import ThrottleActuator +from rocketpy.rocket.actuator.thrust_vector import ThrustVectorActuator from rocketpy.rocket.aero_surface import ( AirBrakes, EllipticalFins, @@ -25,9 +28,6 @@ from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute -from rocketpy.rocket.actuator.roll import RollActuator -from rocketpy.rocket.actuator.throttle import ThrottleActuator -from rocketpy.rocket.actuator.thrust_vector import ThrustVectorActuator from rocketpy.tools import ( deprecated, find_obj_from_hash, From 233666b10e2662e584825161ff97aaef5b16d278 Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sat, 6 Jun 2026 23:20:34 +0100 Subject: [PATCH 06/10] Add pytest for all actuators --- tests/unit/rocket/test_actuators.py | 412 +++++++++++++++++++++++++ tests/unit/rocket/test_roll_control.py | 97 ------ 2 files changed, 412 insertions(+), 97 deletions(-) create mode 100644 tests/unit/rocket/test_actuators.py delete mode 100644 tests/unit/rocket/test_roll_control.py diff --git a/tests/unit/rocket/test_actuators.py b/tests/unit/rocket/test_actuators.py new file mode 100644 index 000000000..97933ac67 --- /dev/null +++ b/tests/unit/rocket/test_actuators.py @@ -0,0 +1,412 @@ +import pytest + +from rocketpy.rocket.actuator.roll import RollActuator +from rocketpy.rocket.actuator.throttle import ThrottleActuator +from rocketpy.rocket.actuator.thrust_vector import ( + ThrustVectorActuator, + ThrustVectorActuator2D, +) + + +class TestRollActuator: + """Test suite for RollActuator class.""" + + def test_initialization_defaults(self): + """Test RollActuator initialization with default parameters.""" + actuator = RollActuator() + assert actuator.name == "Roll Control" + assert actuator.demand_rate == 100 + assert actuator.actuator_range == (0, 0) + assert actuator.roll_torque == 0.0 + assert actuator.actuator_rate_limit is None + assert actuator.clamp is True + + def test_initialization_custom(self): + """Test RollActuator initialization with custom parameters.""" + actuator = RollActuator( + name="Custom Roll", + demand_rate=50, + max_roll_torque=10.0, + torque_rate_limit=5.0, + clamp=False, + initial_roll_torque=2.0, + ) + assert actuator.name == "Custom Roll" + assert actuator.demand_rate == 50 + assert actuator.actuator_range == (-10.0, 10.0) + assert actuator.roll_torque == 2.0 + assert actuator.actuator_rate_limit == 5.0 + assert actuator.clamp is False + + def test_roll_torque_property(self): + """Test roll torque getter and setter.""" + actuator = RollActuator(max_roll_torque=10.0) + actuator.roll_torque = 5.0 + assert actuator.roll_torque == 5.0 + + def test_roll_torque_clamping(self): + """Test roll torque clamping to range.""" + actuator = RollActuator(max_roll_torque=10.0, clamp=True) + actuator.actuator_output = 15.0 + assert actuator.roll_torque == 10.0 + actuator.actuator_output = -15.0 + assert actuator.roll_torque == -10.0 + + def test_to_dict(self): + """Test to_dict serialization.""" + actuator = RollActuator( + name="Test Roll", + max_roll_torque=5.0, + torque_rate_limit=2.0, + initial_roll_torque=1.0, + ) + data = actuator.to_dict() + assert data["name"] == "Test Roll" + assert data["max_roll_torque"] == 5.0 + assert data["torque_rate_limit"] == 2.0 + assert data["initial_roll_torque"] == 1.0 + + def test_from_dict(self): + """Test from_dict deserialization.""" + data = { + "name": "Test Roll", + "demand_rate": 50, + "max_roll_torque": 5.0, + "torque_rate_limit": 2.0, + "clamp": True, + "initial_roll_torque": 1.0, + "roll_torque_time_constant": None, + } + actuator = RollActuator.from_dict(data) + assert actuator.name == "Test Roll" + assert actuator.demand_rate == 50 + assert actuator.roll_torque == 1.0 + + def test_reset(self): + """Test actuator reset functionality.""" + actuator = RollActuator(max_roll_torque=10.0, initial_roll_torque=2.0) + actuator.roll_torque = 5.0 + actuator._reset() + assert actuator.roll_torque == 2.0 + + def test_info_methods(self): + """Test info and all_info methods.""" + actuator = RollActuator() + # Just verify methods exist and don't raise exceptions + actuator.info() + actuator.all_info() + + +class TestThrottleActuator: + """Test suite for ThrottleActuator class.""" + + def test_initialization_defaults(self): + """Test ThrottleActuator initialization with default parameters.""" + actuator = ThrottleActuator() + assert actuator.name == "Throttle Control" + assert actuator.demand_rate == 100 + assert actuator.actuator_range == (0, 1) + assert actuator.throttle == 0.0 + + def test_initialization_custom(self): + """Test ThrottleActuator initialization with custom parameters.""" + actuator = ThrottleActuator( + name="Custom Throttle", + demand_rate=50, + max_throttle=0.8, + throttle_rate_limit=0.1, + initial_throttle=0.5, + ) + assert actuator.name == "Custom Throttle" + assert actuator.actuator_range == (0, 0.8) + assert actuator.throttle == 0.5 + + def test_throttle_property(self): + """Test throttle getter and setter.""" + actuator = ThrottleActuator(max_throttle=1.0) + actuator.throttle = 0.5 + assert actuator.throttle == 0.5 + + def test_throttle_clamping(self): + """Test throttle clamping to range.""" + actuator = ThrottleActuator(max_throttle=1.0, clamp=True) + actuator.actuator_output = 1.5 + assert actuator.throttle == 1.0 + actuator.actuator_output = -0.5 + assert actuator.throttle == 0.0 + + def test_to_dict(self): + """Test to_dict serialization.""" + actuator = ThrottleActuator( + name="Test Throttle", + max_throttle=0.8, + throttle_rate_limit=0.1, + initial_throttle=0.3, + ) + data = actuator.to_dict() + assert data["name"] == "Test Throttle" + assert data["max_throttle"] == 0.8 + assert data["throttle_rate_limit"] == 0.1 + assert data["initial_throttle"] == 0.3 + + def test_from_dict(self): + """Test from_dict deserialization.""" + data = { + "name": "Test Throttle", + "demand_rate": 50, + "max_throttle": 0.8, + "throttle_rate_limit": 0.1, + "clamp": True, + "initial_throttle": 0.3, + "throttle_time_constant": None, + } + actuator = ThrottleActuator.from_dict(data) + assert actuator.name == "Test Throttle" + assert actuator.throttle == 0.3 + + def test_reset(self): + """Test actuator reset functionality.""" + actuator = ThrottleActuator(initial_throttle=0.3) + actuator.throttle = 0.7 + actuator._reset() + assert actuator.throttle == 0.3 + + def test_info_methods(self): + """Test info and all_info methods.""" + actuator = ThrottleActuator() + actuator.info() + actuator.all_info() + + +class TestThrustVectorActuator: + """Test suite for ThrustVectorActuator class.""" + + def test_initialization_defaults(self): + """Test ThrustVectorActuator initialization with default parameters.""" + actuator = ThrustVectorActuator() + assert actuator.name == "Thrust Vector Control" + assert actuator.demand_rate == 100 + assert actuator.actuator_range == (-10, 10) + assert actuator.gimbal_angle == 0.0 + + def test_initialization_custom(self): + """Test ThrustVectorActuator initialization with custom parameters.""" + actuator = ThrustVectorActuator( + name="Custom TVC", + demand_rate=50, + max_gimbal_angle=15.0, + gimbal_rate_limit=5.0, + initial_gimbal_angle=5.0, + ) + assert actuator.name == "Custom TVC" + assert actuator.actuator_range == (-15.0, 15.0) + assert actuator.gimbal_angle == 5.0 + + def test_gimbal_angle_property(self): + """Test gimbal angle getter and setter.""" + actuator = ThrustVectorActuator(max_gimbal_angle=10.0) + actuator.gimbal_angle = 5.0 + assert actuator.gimbal_angle == 5.0 + + def test_gimbal_angle_clamping(self): + """Test gimbal angle clamping to range.""" + actuator = ThrustVectorActuator(max_gimbal_angle=10.0, clamp=True) + actuator.actuator_output = 15.0 + assert actuator.gimbal_angle == 10.0 + actuator.actuator_output = -15.0 + assert actuator.gimbal_angle == -10.0 + + def test_to_dict(self): + """Test to_dict serialization.""" + actuator = ThrustVectorActuator( + name="Test TVC", + max_gimbal_angle=8.0, + gimbal_rate_limit=3.0, + initial_gimbal_angle=2.0, + ) + data = actuator.to_dict() + assert data["name"] == "Test TVC" + assert data["max_gimbal_angle"] == 8.0 + assert data["gimbal_rate_limit"] == 3.0 + assert data["initial_gimbal_angle"] == 2.0 + + def test_from_dict(self): + """Test from_dict deserialization.""" + data = { + "name": "Test TVC", + "demand_rate": 50, + "max_gimbal_angle": 8.0, + "gimbal_rate_limit": 3.0, + "clamp": True, + "initial_gimbal_angle": 2.0, + "gimbal_time_constant": None, + } + actuator = ThrustVectorActuator.from_dict(data) + assert actuator.name == "Test TVC" + assert actuator.gimbal_angle == 2.0 + + def test_reset(self): + """Test actuator reset functionality.""" + actuator = ThrustVectorActuator(initial_gimbal_angle=2.0) + actuator.gimbal_angle = 5.0 + actuator._reset() + assert actuator.gimbal_angle == 2.0 + + def test_info_methods(self): + """Test info and all_info methods.""" + actuator = ThrustVectorActuator() + actuator.info() + actuator.all_info() + + +class TestThrustVectorActuator2D: + """Test suite for ThrustVectorActuator2D class.""" + + def test_initialization_defaults(self): + """Test ThrustVectorActuator2D initialization with default parameters.""" + actuator = ThrustVectorActuator2D() + assert actuator.gimbal_angle_x == 0.0 + assert actuator.gimbal_angle_y == 0.0 + assert actuator.gimbal_angles == (0.0, 0.0) + + def test_initialization_custom(self): + """Test ThrustVectorActuator2D initialization with custom parameters.""" + actuator = ThrustVectorActuator2D( + name="Custom 2D TVC", + max_gimbal_angle=15.0, + initial_gimbal_angle=5.0, + ) + assert actuator.x.name == "Custom 2D TVC X-axis" + assert actuator.y.name == "Custom 2D TVC Y-axis" + assert actuator.gimbal_angles == (5.0, 5.0) + + def test_gimbal_angle_x_property(self): + """Test gimbal angle X getter and setter.""" + actuator = ThrustVectorActuator2D() + actuator.gimbal_angle_x = 5.0 + assert actuator.gimbal_angle_x == 5.0 + + def test_gimbal_angle_y_property(self): + """Test gimbal angle Y getter and setter.""" + actuator = ThrustVectorActuator2D() + actuator.gimbal_angle_y = 7.0 + assert actuator.gimbal_angle_y == 7.0 + + def test_gimbal_angles_property_get(self): + """Test gimbal angles tuple getter.""" + actuator = ThrustVectorActuator2D() + actuator.gimbal_angle_x = 3.0 + actuator.gimbal_angle_y = 4.0 + assert actuator.gimbal_angles == (3.0, 4.0) + + def test_gimbal_angles_property_set(self): + """Test gimbal angles tuple setter.""" + actuator = ThrustVectorActuator2D() + actuator.gimbal_angles = (5.0, 7.0) + assert actuator.gimbal_angle_x == 5.0 + assert actuator.gimbal_angle_y == 7.0 + assert actuator.gimbal_angles == (5.0, 7.0) + + def test_independent_axes(self): + """Test that X and Y axes are independent.""" + actuator = ThrustVectorActuator2D(max_gimbal_angle=10.0) + actuator.gimbal_angle_x = 5.0 + actuator.gimbal_angle_y = -3.0 + assert actuator.gimbal_angle_x == 5.0 + assert actuator.gimbal_angle_y == -3.0 + assert actuator.gimbal_angles == (5.0, -3.0) + + def test_clamping_individual_axes(self): + """Test clamping on individual axes.""" + actuator = ThrustVectorActuator2D(max_gimbal_angle=10.0, clamp=True) + # Test X axis clamping + actuator.x.actuator_output = 15.0 + assert actuator.gimbal_angle_x == 10.0 + # Test Y axis clamping + actuator.y.actuator_output = -15.0 + assert actuator.gimbal_angle_y == -10.0 + + +class TestActuatorDynamics: + """Test suite for actuator dynamics (rate limiting, time constants).""" + + def test_rate_limiting_roll(self): + """Test rate limiting on roll actuator.""" + actuator = RollActuator( + max_roll_torque=10.0, + demand_rate=100, + torque_rate_limit=5.0, + ) + # Change should be limited + actuator.actuator_output = 10.0 # Try to jump to 10 + # At 100 Hz demand rate: max_change = 5.0 / 100 = 0.05 + # So output should be clamped to 0.05 + assert actuator.roll_torque <= 0.1 # Small due to rate limit + + def test_no_rate_limiting_when_none(self): + """Test that no rate limiting occurs when set to None.""" + actuator = RollActuator( + max_roll_torque=10.0, + torque_rate_limit=None, + ) + actuator.actuator_output = 5.0 + assert actuator.roll_torque == 5.0 + + def test_time_constant_iir_filter(self): + """Test IIR filter behavior with time constant.""" + actuator = ThrottleActuator( + max_throttle=1.0, + demand_rate=100, + throttle_time_constant=0.1, + ) + # With time constant, output should be filtered + # alpha = Ts / (tau + Ts) = 0.01 / (0.1 + 0.01) ≈ 0.0909 + initial_output = actuator.throttle + actuator.actuator_output = 1.0 + filtered_output = actuator.throttle + # Output should be between initial and 1.0 due to filtering + assert initial_output < filtered_output < 1.0 + + +class TestActuatorValidation: + """Test suite for actuator parameter validation.""" + + def test_invalid_demand_rate_negative(self): + """Test that negative demand rate raises assertion error.""" + with pytest.raises(AssertionError): + RollActuator(demand_rate=-1) + + def test_invalid_range(self): + """Test that invalid range raises assertion error.""" + with pytest.raises(AssertionError): + RollActuator( + max_roll_torque=-5 + ) # This creates range (5, -5) which is invalid + + def test_invalid_time_constant_negative(self): + """Test that negative time constant raises assertion error.""" + with pytest.raises(AssertionError): + ThrottleActuator(throttle_time_constant=-0.1) + + def test_invalid_rate_limit_negative(self): + """Test that negative rate limit raises assertion error.""" + with pytest.raises(AssertionError): + ThrustVectorActuator(gimbal_rate_limit=-1.0) + + +class TestActuatorWarnings: + """Test suite for actuator warning conditions.""" + + def test_clamp_false_no_clamping(self): + """Test that output is not clamped when clamp=False.""" + actuator = RollActuator(max_roll_torque=10.0, clamp=False) + actuator.actuator_output = 15.0 + # Output should not be clamped when clamp=False + assert actuator.roll_torque == 15.0 + + def test_clamping_applied(self): + """Test that clamping is applied when clamp=True.""" + actuator = RollActuator(max_roll_torque=10.0, clamp=True) + actuator.actuator_output = 15.0 + # Output should be clamped to range + assert actuator.roll_torque == 10.0 diff --git a/tests/unit/rocket/test_roll_control.py b/tests/unit/rocket/test_roll_control.py deleted file mode 100644 index 17181e3b9..000000000 --- a/tests/unit/rocket/test_roll_control.py +++ /dev/null @@ -1,97 +0,0 @@ -import pytest - -from rocketpy.rocket.actuator.roll import RollControl -from rocketpy.rocket.rocket import Rocket - - -def test_roll_torque_actuator_dynamics_filters_command(): - roll_control = RollControl( - sampling_rate=10, - max_roll_torque=100, - torque_rate_limit=1000, - roll_torque=0, - actuator_tau=0.4, - ) - - roll_control.roll_torque = 10 - - assert roll_control.roll_torque == pytest.approx(2) - assert roll_control.roll_torque_prev == pytest.approx(10) - assert roll_control.roll_torque_filtered == pytest.approx(2) - - roll_control.roll_torque = 10 - - assert roll_control.roll_torque == pytest.approx(3.6) - - -def test_roll_torque_without_actuator_dynamics_is_pass_through(): - roll_control = RollControl( - sampling_rate=10, - max_roll_torque=100, - torque_rate_limit=1000, - roll_torque=0, - ) - - roll_control.roll_torque = 10 - - assert roll_control.roll_torque == pytest.approx(10) - - -def test_roll_control_reset_restores_filtered_state(): - roll_control = RollControl( - sampling_rate=10, - max_roll_torque=100, - torque_rate_limit=1000, - roll_torque=5, - actuator_tau=0.4, - ) - roll_control.roll_torque = 25 - - roll_control._reset() - - assert roll_control.roll_torque == pytest.approx(5) - assert roll_control.roll_torque_prev == pytest.approx(5) - assert roll_control.roll_torque_filtered == pytest.approx(5) - - -def test_roll_control_serializes_actuator_tau(): - roll_control = RollControl( - sampling_rate=20, - max_roll_torque=50, - torque_rate_limit=200, - clamp=False, - roll_torque=3, - actuator_tau=0.2, - name="Test Roll Control", - ) - - data = roll_control.to_dict() - restored = RollControl.from_dict(data) - - assert data["actuator_tau"] == pytest.approx(0.2) - assert restored.actuator_tau == pytest.approx(0.2) - assert restored.roll_torque == pytest.approx(3) - - -def test_rocket_add_roll_control_forwards_actuator_tau(): - def controller_function(*_): - return None - - rocket = Rocket( - radius=0.1, - mass=10, - inertia=(1, 1, 1), - power_off_drag=0.5, - power_on_drag=0.5, - center_of_mass_without_motor=0, - ) - - roll_control = rocket.add_roll_control( - max_roll_torque=20, - torque_rate_limit=100, - controller_function=controller_function, - sampling_rate=50, - actuator_tau=0.3, - ) - - assert roll_control.actuator_tau == pytest.approx(0.3) From 9892e505d27e4fe4f7b469b777ccb357114805af Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sat, 6 Jun 2026 23:50:22 +0100 Subject: [PATCH 07/10] Update rocket.py and flight.py for actuator class update --- rocketpy/rocket/actuator/throttle.py | 22 ++-- rocketpy/rocket/rocket.py | 157 ++++++++++++++------------- rocketpy/simulation/flight.py | 47 +++++--- 3 files changed, 124 insertions(+), 102 deletions(-) diff --git a/rocketpy/rocket/actuator/throttle.py b/rocketpy/rocket/actuator/throttle.py index 3ad7115f4..6276100a3 100644 --- a/rocketpy/rocket/actuator/throttle.py +++ b/rocketpy/rocket/actuator/throttle.py @@ -41,10 +41,10 @@ def __init__( self, name="Throttle Control", demand_rate=100, - max_throttle=1, + throttle_range=(0, 1), throttle_rate_limit=None, clamp=True, - initial_throttle=0.0, + initial_throttle=1.0, throttle_time_constant=None, ): """Initializes the ThrottleActuator class. @@ -56,19 +56,18 @@ def __init__( demand_rate : int, optional Demand rate of the throttle actuator in Hz. Default is 100 Hz. None indicates a continuous-time actuator. - max_throttle : float, int - Maximum throttle value. Must be non-negative. - Default is 1 (full throttle). + throttle_range : tuple, optional + A tuple containing the minimum and maximum throttle values. Default is (0.0, 1.0). throttle_rate_limit : float, int Maximum throttle rate in 1/s. Throttle is limited to this rate. Must be non-negative. Default is None (no limit). demand_rate must be specified if throttle_rate_limit is not None. clamp : bool, optional If True, the simulation will clamp throttle to the range - [0, max_throttle] if it exceeds this range. + [throttle_range[0], throttle_range[1]] if it exceeds this range. If False, the simulation will issue a warning if throttle exceeds the maximum value. Default is True. initial_throttle : float, optional - Initial throttle value. Default is 0.0 (no thrust). + Initial throttle value. Default is 1.0 (full thrust). throttle_time_constant : float, optional Time constant for the throttle actuator dynamics (first-order IIR filter) in seconds. If None, no actuator dynamics are applied. @@ -81,7 +80,7 @@ def __init__( super().__init__( name=name, demand_rate=demand_rate, - actuator_range=(0, max_throttle), + actuator_range=throttle_range, actuator_rate_limit=throttle_rate_limit, clamp=clamp, actuator_initial_output=initial_throttle, @@ -121,7 +120,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument return { "name": self.name, "demand_rate": self.demand_rate, - "max_throttle": self.actuator_range[1], + "throttle_range": self.actuator_range, "throttle_rate_limit": self.actuator_rate_limit, "clamp": self.clamp, "initial_throttle": self.actuator_initial_output, @@ -133,7 +132,10 @@ def from_dict(cls, data): return cls( name=data.get("name"), demand_rate=data.get("demand_rate"), - max_throttle=data.get("max_throttle"), + throttle_range=( + data.get("throttle_range")[0], + data.get("throttle_range")[1], + ), throttle_rate_limit=data.get("throttle_rate_limit"), clamp=data.get("clamp"), initial_throttle=data.get("initial_throttle"), diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index f92080be5..f927f3e4d 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -14,7 +14,7 @@ from rocketpy.prints.rocket_prints import _RocketPrints from rocketpy.rocket.actuator.roll import RollActuator from rocketpy.rocket.actuator.throttle import ThrottleActuator -from rocketpy.rocket.actuator.thrust_vector import ThrustVectorActuator +from rocketpy.rocket.actuator.thrust_vector import ThrustVectorActuator2D from rocketpy.rocket.aero_surface import ( AirBrakes, EllipticalFins, @@ -1827,19 +1827,19 @@ def add_air_brakes( else: return air_brakes - def add_tvc( + def add_thrust_vector_control( self, - gimbal_range, - gimbal_rate_limit, controller_function, sampling_rate, + max_gimbal_angle, + gimbal_rate_limit=None, clamp=True, + initial_gimbal_angle=0.0, + gimbal_time_constant=None, initial_observed_variables=None, return_controller=False, - actuator_tau_x=None, - actuator_tau_y=None, - name="TVC", - controller_name="TVC Controller", + name="Thrust Vector Control", + controller_name="Thrust Vector Controller", ): """Creates a new thrust vector control (TVC) system, storing its parameters such as gimbal angle maximum controller function, and @@ -1847,13 +1847,6 @@ def add_tvc( Parameters ---------- - gimbal_range : int, float - Maximum gimbal range in degrees. Both x and y gimbal - angles are clamped to this range if clamp is True. Must be - non-negative. - gimbal_rate_limit : int, float - Maximum gimbal rate in degrees per second. Both x and y gimbal - angles are limited to this rate of change. Must be non-negative. controller_function : function, callable An user-defined function responsible for controlling the TVC system. This function is expected to take the following arguments, in order: @@ -1892,10 +1885,25 @@ def add_tvc( The sampling rate of the controller function in Hertz (Hz). This means that the controller function will be called every `1/sampling_rate` seconds. + max_gimbal_angle : int, float + Maximum gimbal angle in degrees. Both x and y gimbal + angles are clamped to this range if clamp is True. Must be + non-negative. + gimbal_rate_limit : int, float + Maximum gimbal rate in degrees per second. Both x and y gimbal + angles are limited to this rate of change. Default is None, no rate limit. clamp : bool, optional If True, the simulation will clamp gimbal angles to the range - [-gimbal_range, gimbal_range]. If False, a warning is + [-max_gimbal_angle, max_gimbal_angle]. If False, a warning is issued when gimbal angles exceed the range. Default is True. + initial_gimbal_angle : int, float, tuple, list + The initial gimbal angle in degrees. If a single value is provided, + it is used for both x and y gimbal angles. If a tuple or list is + provided, the first element is used for the x-axis and the second + for the y-axis. Default is 0.0. + gimbal_time_constant : float, optional + Time constant for the gimbal dynamics in seconds. If None, no + gimbal dynamics are applied. Default is None. initial_observed_variables : list, optional A list of the initial values of the variables that the controller function manages. This list is used to initialize the @@ -1904,85 +1912,76 @@ def add_tvc( return_controller : bool, optional If True, the function will return the controller object created. Default is False. - actuator_tau_x : float, optional - Time constant for the x-axis actuator dynamics in seconds. - If None, no actuator dynamics are applied on the x axis. - actuator_tau_y : float, optional - Time constant for the y-axis actuator dynamics in seconds. - If None, no actuator dynamics are applied on the y axis. name : string, optional - TVC system name. Has no impact in simulation, as it is only used to - display data in a more organized matter. Default is "TVC". + thrust_vector_control system name. Has no impact in simulation, as it is only used to + display data in a more organized matter. Default is "Thrust Vector Control". controller_name : string, optional Controller name. Has no impact in simulation, as it is only used to - display data in a more organized matter. Default is "TVC Controller". + display data in a more organized matter. Default is "Thrust Vector Controller". Returns ------- - tvc : TVC - TVC object created. + thrust_vector_control : ThrustVectorActuator2D + ThrustVectorActuator2D object created. controller : Controller, optional Controller object created (only if return_controller is True). """ - if hasattr(self, "tvc"): + if hasattr(self, "thrust_vector_control"): # pylint: disable=access-member-before-definition print( - "Only one TVC per rocket is currently supported. " - + "Overwriting previous TVC and controllers." + "Only one thrust_vector_control per rocket is currently supported. " + + "Overwriting previous thrust_vector_control and controllers." ) self._controllers = [ controller for controller in self._controllers - if not isinstance(controller.interactive_objects, ThrustVectorActuator) + if not isinstance( + controller.interactive_objects, ThrustVectorActuator2D + ) ] - tvc = ThrustVectorActuator( - sampling_rate=sampling_rate, - gimbal_range=gimbal_range, + thrust_vector_control = ThrustVectorActuator2D( + name=name, + demand_rate=sampling_rate, + max_gimbal_angle=max_gimbal_angle, gimbal_rate_limit=gimbal_rate_limit, clamp=clamp, - gimbal_angle_x=0, - gimbal_angle_y=0, - actuator_tau_x=actuator_tau_x, - actuator_tau_y=actuator_tau_y, - name=name, + initial_gimbal_angle=initial_gimbal_angle, + gimbal_time_constant=gimbal_time_constant, ) _controller = _Controller( - interactive_objects=tvc, + interactive_objects=thrust_vector_control, controller_function=controller_function, sampling_rate=sampling_rate, initial_observed_variables=initial_observed_variables, name=controller_name, ) - self.tvc = tvc + self.thrust_vector_control = thrust_vector_control self._add_controllers(_controller) if return_controller: - return tvc, _controller + return thrust_vector_control, _controller else: - return tvc + return thrust_vector_control def add_roll_control( self, - max_roll_torque, - torque_rate_limit, controller_function, sampling_rate, + max_roll_torque, + torque_rate_limit=None, clamp=True, + initial_roll_torque=0.0, + roll_torque_time_constant=None, initial_observed_variables=None, return_controller=False, name="Roll Control", - controller_name="Roll Control Controller", - actuator_tau=None, + controller_name="Roll Controller", ): """Creates a new roll control system, storing its parameters such as maximum roll torque, controller function, and sampling rate. Parameters ---------- - max_roll_torque : int, float - Maximum roll torque magnitude in N·m. Must be non-negative. - torque_rate_limit : int, float - Maximum rate of change of roll torque in N·m/s. Must be non-negative. controller_function : function, callable An user-defined function responsible for controlling the roll control system. This function is expected to take the following arguments, in @@ -2016,17 +2015,25 @@ def add_roll_control( .. note:: - The function will be called according to the sampling rate - specified. + The function will be called according to the sampling rate specified. sampling_rate : float The sampling rate of the controller function in Hertz (Hz). This means that the controller function will be called every `1/sampling_rate` seconds. + max_roll_torque : int, float + Maximum roll torque magnitude in N·m. Must be non-negative. + torque_rate_limit : int, float + Maximum rate of change of roll torque in N·m/s. Must be non-negative. + Default is None, which means no rate limit. clamp : bool, optional If True, the simulation will clamp roll torque to the range [-max_roll_torque, max_roll_torque]. If False, a warning is issued when roll torque exceeds the range. Default is True. + Initial_roll_torque : int, float + Initial roll torque in N·m. Default is 0.0. + roll_torque_time_constant : float, optional + Time constant for the roll torque dynamics in seconds. Default is None, no dynamics are applied. initial_observed_variables : list, optional A list of the initial values of the variables that the controller function manages. This list is used to initialize the @@ -2042,10 +2049,7 @@ def add_roll_control( controller_name : string, optional Controller name. Has no impact in simulation, as it is only used to display data in a more organized matter. Default is - "Roll Control Controller". - actuator_tau : float, optional - Time constant for the roll torque actuator dynamics in seconds. - If None, no actuator dynamics are applied. + "Roll Controller". Returns ------- @@ -2067,13 +2071,13 @@ def add_roll_control( ] roll_control = RollActuator( - sampling_rate=sampling_rate, + name=name, + demand_rate=sampling_rate, max_roll_torque=max_roll_torque, torque_rate_limit=torque_rate_limit, clamp=clamp, - roll_torque=0, - actuator_tau=actuator_tau, - name=name, + initial_roll_torque=initial_roll_torque, + roll_torque_time_constant=roll_torque_time_constant, ) _controller = _Controller( interactive_objects=roll_control, @@ -2094,12 +2098,12 @@ def add_throttle_control( controller_function, sampling_rate, throttle_range=(0, 1), - throttle=1.0, - clamp=True, throttle_rate_limit=0, + clamp=True, + initial_throttle=1.0, + throttle_time_constant=None, initial_observed_variables=None, return_controller=False, - actuator_tau=None, name="Throttle Control", controller_name="Throttle Controller", ): @@ -2149,18 +2153,17 @@ def add_throttle_control( `1/sampling_rate` seconds. throttle_range : tuple, optional A tuple containing the minimum and maximum throttle values. Must be in the range [0, 1]. Default is (0.0, 1.0). - - initial_throttle : float, optional - Initial throttle value at the start of the simulation. Must be within - the range [throttle_range[0], throttle_range[1]]. Default is 1.0. + throttle_rate_limit : float, optional + Maximum throttle rate in 1/s. Throttle is limited to this rate. + Must be non-negative. Default is None, no throttle change limit. clamp : bool, optional If True, the simulation will clamp throttle values to the range [throttle_range[0], throttle_range[1]]. If False, a warning is issued when throttle values exceed the range. Default is True. - throttle_rate_limit : float, optional - Maximum throttle rate in 1/s. Throttle is limited to this rate. - Must be non-negative. Default is 0 (no throttle change limit). - actuator_tau : float, optional + initial_throttle : float, optional + Initial throttle value at the start of the simulation. Must be within + the range [throttle_range[0], throttle_range[1]]. Default is 1.0. + throttle_time_constant : float, optional Time constant for the throttle actuator dynamics in seconds. If None, no actuator dynamics are applied. initial_observed_variables : list, optional @@ -2200,13 +2203,13 @@ def add_throttle_control( ] throttle_control = ThrottleActuator( + name=name, + demand_rate=sampling_rate, throttle_range=throttle_range, - throttle=throttle, - clamp=clamp, - sampling_rate=sampling_rate, throttle_rate_limit=throttle_rate_limit, - actuator_tau=actuator_tau, - name=name, + clamp=clamp, + initial_throttle=initial_throttle, + throttle_time_constant=throttle_time_constant, ) _controller = _Controller( diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index c565482b1..6ff08fc5c 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1753,11 +1753,12 @@ def __init_controllers(self): "time_overshoot has been set to False due to the presence " "of controllers or sensors. " ) - # reset controllable objects to initial state (air brakes, TVC, throttle control, and roll control) + # reset controllable objects to initial state (air brakes, thrust vector control, throttle control, and roll control) for air_brakes in self.rocket.air_brakes: air_brakes._reset() - if hasattr(self.rocket, "tvc"): - self.rocket.tvc._reset() + if hasattr(self.rocket, "thrust_vector_control"): + self.rocket.thrust_vector_control.x._reset() + self.rocket.thrust_vector_control.y._reset() if hasattr(self.rocket, "roll_control"): self.rocket.roll_control._reset() if hasattr(self.rocket, "throttle_control"): @@ -2052,23 +2053,33 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals getattr(self.rocket, "throttle_control", None), "throttle", 1.0 ) - # TVC (Thrust Vector Control) - if hasattr(self.rocket, "tvc"): + # Thrust Vector Control (TVC)) + if hasattr(self.rocket, "thrust_vector_control"): # TVC Fz thrust: F = T * sqrt(1 - sin(gimbal_angle_x)**2 - sin(gimbal_angle_y)**2) thrust3 = effective_thrust * np.sqrt( 1 - - np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) ** 2 - - np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) ** 2 + - np.sin( + self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) + ) + ** 2 + - np.sin( + self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) + ) + ** 2 ) tvc_lever = self.rocket.nozzle_to_cdm # TVC Mx My moments: M = T * sin(x) * r M1 += ( - np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) + np.sin( + self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) + ) * effective_thrust * tvc_lever ) M2 += ( - np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) + np.sin( + self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) + ) * effective_thrust * tvc_lever ) @@ -2773,25 +2784,31 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too getattr(self.rocket, "throttle_control", None), "throttle", 1.0 ) - # TVC (Thrust Vector Control) - if hasattr(self.rocket, "tvc"): + # Thrust Vector Control (TVC) + if hasattr(self.rocket, "thrust_vector_control"): tvc_lever = self.rocket.nozzle_to_cdm # TVC Mx My moments: M = T * sin(x) * r M1 += ( - np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) + np.sin(self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180)) * effective_thrust * tvc_lever ) M2 += ( - np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) + np.sin(self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180)) * effective_thrust * tvc_lever ) # TVC Fz thrust: F = T * sqrt(1 - sin^2(x) - sin^2(y)) thrust3 = effective_thrust * np.sqrt( 1 - - np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) ** 2 - - np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) ** 2 + - np.sin( + self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) + ) + ** 2 + - np.sin( + self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) + ) + ** 2 ) else: thrust3 = effective_thrust From f13f92a27ac96fed5e5d7e59121355dfbbaef806 Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sun, 7 Jun 2026 00:30:53 +0100 Subject: [PATCH 08/10] Fix actuator class calls --- rocketpy/rocket/actuator/roll.py | 20 ++++++++++---------- rocketpy/rocket/actuator/throttle.py | 20 ++++++++++---------- rocketpy/rocket/actuator/thrust_vector.py | 22 +++++++++++----------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/rocketpy/rocket/actuator/roll.py b/rocketpy/rocket/actuator/roll.py index 95aa4b742..20127d581 100644 --- a/rocketpy/rocket/actuator/roll.py +++ b/rocketpy/rocket/actuator/roll.py @@ -14,22 +14,22 @@ class RollActuator(Actuator): Attributes ---------- - RollActuator.name : str + name : str Name of the roll actuator. - RollActuator.demand_rate : float + demand_rate : float Demand rate of the roll control in Hz. None indicates a continuous-time actuator. - RollActuator.actuator_range : float + actuator_range : float Range of the roll control in N·m. - RollActuator.actuator_rate_limit : float + actuator_rate_limit : float Rate limit of the roll control in N·m/s. The roll torque change is limited to this rate. - RollActuator.clamp : bool, optional + clamp : bool, optional If True, roll torque is clamped to actuator_range. If False, a warning is issued when roll torque exceeds the range. - RollActuator.actuator_time_constant : float + actuator_time_constant : float Time constant for the roll torque actuator dynamics (first-order IIR filter) in seconds. - RollActuator.actuator_initial_output : float + actuator_initial_output : float Initial roll torque in N·m. - RollActuator.roll_torque : float + roll_torque : float Current roll torque output magnitude in N·m (Newton-meters). Positive values indicate counter-clockwise rotation when viewed from the nose of the rocket. @@ -90,12 +90,12 @@ def __init__( @property def roll_torque(self): """Returns the current roll torque in N·m.""" - return self._actuator_output + return self.actuator_output @roll_torque.setter def roll_torque(self, value): """Sets the roll torque in N·m.""" - self._actuator_output = value + self.actuator_output = value def info(self): """Prints summarized information of the roll control system. diff --git a/rocketpy/rocket/actuator/throttle.py b/rocketpy/rocket/actuator/throttle.py index 6276100a3..34c6016fd 100644 --- a/rocketpy/rocket/actuator/throttle.py +++ b/rocketpy/rocket/actuator/throttle.py @@ -17,22 +17,22 @@ class ThrottleActuator(Actuator): Attributes ---------- - ThrottleActuator.name : str + name : str Name of the throttle actuator. - ThrottleActuator.demand_rate : float + demand_rate : float Demand rate of the throttle actuator in Hz. None indicates a continuous-time actuator. - ThrottleActuator.actuator_range : float + actuator_range : float Range of the throttle actuator. - ThrottleActuator.actuator_rate_limit : float + actuator_rate_limit : float Rate limit of the throttle actuator in 1/s. The throttle change is limited to this rate. - ThrottleActuator.clamp : bool, optional + clamp : bool, optional If True, throttle is clamped to actuator_range. If False, a warning is issued when throttle exceeds the range. - ThrottleActuator.actuator_time_constant : float + actuator_time_constant : float Time constant for the throttle actuator dynamics (first-order IIR filter) in seconds. - ThrottleActuator.actuator_initial_output : float + actuator_initial_output : float Initial throttle value. - ThrottleActuator.throttle : float + throttle : float Current throttle value. The throttle is the fraction of the maximum thrust produced by the engine ranging from 0 (no thrust) to 1 (full thrust). """ @@ -91,12 +91,12 @@ def __init__( @property def throttle(self): """Returns the current throttle value.""" - return self._actuator_output + return self.actuator_output @throttle.setter def throttle(self, value): """Sets the throttle value.""" - self._actuator_output = value + self.actuator_output = value def info(self): """Prints summarized information of the throttle control system. diff --git a/rocketpy/rocket/actuator/thrust_vector.py b/rocketpy/rocket/actuator/thrust_vector.py index 992913a5d..43808b4e7 100644 --- a/rocketpy/rocket/actuator/thrust_vector.py +++ b/rocketpy/rocket/actuator/thrust_vector.py @@ -13,22 +13,22 @@ class ThrustVectorActuator(Actuator): Attributes ---------- - ThrustVectorActuator.name : str + name : str Name of the thrust vector actuator. - ThrustVectorActuator.demand_rate : float + demand_rate : float Demand rate of the thrust vector actuator in Hz. None indicates a continuous-time actuator. - ThrustVectorActuator.actuator_range : float + actuator_range : float Range of the thrust vector actuator in deg. - ThrustVectorActuator.actuator_rate_limit : float + actuator_rate_limit : float Rate limit of the thrust vector actuator in deg/sec. The thrust vector change is limited to this rate. - ThrustVectorActuator.clamp : bool, optional + clamp : bool, optional If True, thrust gimbal angle is clamped to actuator_range. If False, a warning is issued when thrust vector exceeds the range. - ThrustVectorActuator.actuator_time_constant : float + actuator_time_constant : float Time constant for the thrust vector actuator dynamics (first-order IIR filter) in seconds. - ThrustVectorActuator.actuator_initial_output : float + actuator_initial_output : float Initial thrust vector gimbal angle in deg. - ThrustVectorActuator._actuator_output : float + gimbal_angle : float Current thrust vector gimbal angle in deg. """ @@ -90,12 +90,12 @@ def __init__( @property def gimbal_angle(self): """Returns the current gimbal angle in deg.""" - return self._actuator_output + return self.actuator_output @gimbal_angle.setter def gimbal_angle(self, value): """Sets the gimbal angle in deg.""" - self._actuator_output = value + self.actuator_output = value def info(self): """Prints summarized information of the thrust vector actuator. @@ -164,7 +164,7 @@ class ThrustVectorActuator2D: Time constant for the thrust vector actuator dynamics (first-order IIR filter) in seconds. ThrustVectorActuator2D.actuator_initial_output : float Initial thrust gimbal angles in deg. - ThrustVectorActuator2D._actuator_output : float + ThrustVectorActuator2D.actuator_output : float Current thrust vector gimble angles in deg. """ From 32bf679952806a14741b7bb96bd916f38230dcb6 Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sun, 7 Jun 2026 00:31:20 +0100 Subject: [PATCH 09/10] Update active control example with new actuator class --- ...> halcyon_flight_sim_active_control.ipynb} | 67 +++++++------------ 1 file changed, 25 insertions(+), 42 deletions(-) rename docs/examples/{halcyon_flight_sim_roll_throttle.ipynb => halcyon_flight_sim_active_control.ipynb} (91%) diff --git a/docs/examples/halcyon_flight_sim_roll_throttle.ipynb b/docs/examples/halcyon_flight_sim_active_control.ipynb similarity index 91% rename from docs/examples/halcyon_flight_sim_roll_throttle.ipynb rename to docs/examples/halcyon_flight_sim_active_control.ipynb index d33ecb012..3e2878665 100644 --- a/docs/examples/halcyon_flight_sim_roll_throttle.ipynb +++ b/docs/examples/halcyon_flight_sim_active_control.ipynb @@ -44,14 +44,11 @@ " Accelerometer,\n", " Environment,\n", " Flight,\n", - " Function,\n", - " GnssReceiver,\n", " Gyroscope,\n", " Rocket,\n", ")\n", "from rocketpy.motors import CylindricalTank, Fluid, HybridMotor\n", "from rocketpy.motors.tank import MassFlowRateBasedTank\n", - "from rocketpy.tools import quaternions_to_spin\n", "\n", "plt.style.use(\"seaborn-v0_8-colorblind\")" ] @@ -284,30 +281,36 @@ "outputs": [], "source": [ "def tvc_controller_function(\n", - " time, sampling_rate, state, state_history, observed_variables, tvc, sensors\n", + " time,\n", + " sampling_rate,\n", + " state,\n", + " state_history,\n", + " observed_variables,\n", + " thrust_vector_control,\n", + " sensors,\n", "):\n", " # state = [x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]\n", "\n", " # print(time)\n", "\n", - " tvc.gimbal_angle_x = 0\n", - " tvc.gimbal_angle_y = 0\n", + " thrust_vector_control.gimbal_angle_x = 0\n", + " thrust_vector_control.gimbal_angle_y = 0\n", " # Return variables of interest to be saved in the observed_variables list\n", " return (\n", " time,\n", - " tvc.gimbal_angle_x,\n", - " tvc.gimbal_angle_y,\n", + " thrust_vector_control.gimbal_angle_x,\n", + " thrust_vector_control.gimbal_angle_y,\n", " )\n", "\n", "\n", - "tvc, tvc_controller = HALCYON.add_tvc(\n", - " gimbal_range=10,\n", + "thrust_vector_control, tvc_controller = HALCYON.add_thrust_vector_control(\n", + " controller_function=tvc_controller_function,\n", " sampling_rate=100,\n", + " max_gimbal_angle=10,\n", " gimbal_rate_limit=100,\n", - " controller_function=tvc_controller_function,\n", " return_controller=True,\n", ")\n", - "tvc.info()\n", + "thrust_vector_control.x.info()\n", "tvc_controller.info()" ] }, @@ -380,10 +383,10 @@ "\n", "\n", "roll_control, roll_controller = HALCYON.add_roll_control(\n", - " max_roll_torque=10,\n", - " sampling_rate=100,\n", - " torque_rate_limit=100,\n", " controller_function=roll_controller_function,\n", + " sampling_rate=100,\n", + " max_roll_torque=10,\n", + " torque_rate_limit=1000,\n", " return_controller=True,\n", ")\n", "roll_control.info()\n", @@ -417,28 +420,17 @@ " time,\n", " throttle_command,\n", " throttle_actual,\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + " )\n", + "\n", + "\n", "throttle_obj, throttle_controller = HALCYON.add_throttle_control(\n", " controller_function=throttle_controller_function,\n", " sampling_rate=100,\n", " throttle_range=(0.0, 1.0),\n", " throttle_rate_limit=100,\n", - " actuator_tau=0.2,\n", - " throttle=1.0,\n", + " throttle_time_constant=0.5,\n", " return_controller=True,\n", - " clamp=True,\n", - ")\n", - "print(\"has add_throttle_control:\", hasattr(HALCYON, \"add_throttle_control\"))\n", - "print(\"has throttle_control:\", hasattr(HALCYON, \"throttle_control\"))\n", - "print(\"throttle_control:\", getattr(HALCYON, \"throttle_control\", None))" + ")" ] }, { @@ -497,15 +489,6 @@ "time1, ax, ay, az = zip(*accelerometer_clean.measured_data)\n", "time2, gx, gy, gz = zip(*gyro_clean.measured_data)\n", "\n", - "# plt.plot(time1, ax, label=\"Accelerometer X\")\n", - "# plt.plot(time1, ay, label=\"Accelerometer Y\")\n", - "# plt.plot(time1, az, label=\"Accelerometer Z\")\n", - "# plt.xlabel(\"Time (s)\")\n", - "# plt.ylabel(\"Acceleration (m/s^2)\")\n", - "# plt.legend()\n", - "# plt.grid()\n", - "# plt.show()\n", - "\n", "plt.plot(time2, gx, label=\"Gyroscope X\")\n", "plt.plot(time2, gy, label=\"Gyroscope Y\")\n", "plt.plot(time2, gz, label=\"Gyroscope Z\")\n", @@ -538,7 +521,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv (3.12.10)", + "display_name": ".venv (3.14.3)", "language": "python", "name": "python3" }, @@ -552,7 +535,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.10" + "version": "3.14.3" } }, "nbformat": 4, From 53e588d5a93ea4f4588e24519bc4224bfbd5df5d Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Sun, 7 Jun 2026 01:13:29 +0100 Subject: [PATCH 10/10] Update time_overshoot in flight.py to align rocketpy commit #45a89 https://github.com/RocketPy-Team/RocketPy/commit/45a891eb412d9ab93fb63ca73a1e3959663a394f --- rocketpy/simulation/flight.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 6ff08fc5c..ea2dd41dc 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1746,23 +1746,17 @@ def __init_controllers(self): """Initialize controllers and sensors""" self._controllers = self.rocket._controllers[:] self.sensors = self.rocket.sensors.get_components() - if self._controllers or self.sensors: - if self.time_overshoot: # pragma: no cover - self.time_overshoot = False - warnings.warn( - "time_overshoot has been set to False due to the presence " - "of controllers or sensors. " - ) - # reset controllable objects to initial state (air brakes, thrust vector control, throttle control, and roll control) - for air_brakes in self.rocket.air_brakes: - air_brakes._reset() - if hasattr(self.rocket, "thrust_vector_control"): - self.rocket.thrust_vector_control.x._reset() - self.rocket.thrust_vector_control.y._reset() - if hasattr(self.rocket, "roll_control"): - self.rocket.roll_control._reset() - if hasattr(self.rocket, "throttle_control"): - self.rocket.throttle_control._reset() + + # reset controllable objects to initial state (air brakes, thrust vector control, throttle control, and roll control) + for air_brakes in self.rocket.air_brakes: + air_brakes._reset() + if hasattr(self.rocket, "thrust_vector_control"): + self.rocket.thrust_vector_control.x._reset() + self.rocket.thrust_vector_control.y._reset() + if hasattr(self.rocket, "roll_control"): + self.rocket.roll_control._reset() + if hasattr(self.rocket, "throttle_control"): + self.rocket.throttle_control._reset() self.sensor_data = {} for sensor in self.sensors: sensor._reset(self.rocket) # resets noise and measurement list