From 9adc5fdd957791d46342100b1a611fe4f5df74c5 Mon Sep 17 00:00:00 2001 From: zuorenchen Date: Tue, 17 Mar 2026 23:45:13 +0000 Subject: [PATCH 1/9] Run formatter --- rocketpy/environment/environment.py | 24 +++++---- rocketpy/rocket/aero_surface/nose_cone.py | 40 +++++++++----- rocketpy/rocket/aero_surface/tail.py | 10 ++-- rocketpy/rocket/parachute.py | 7 +-- rocketpy/rocket/rocket.py | 18 ++++--- rocketpy/simulation/flight.py | 64 +++++++++++++---------- 6 files changed, 98 insertions(+), 65 deletions(-) diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index eb2eacd5a..dae2a4976 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -2434,22 +2434,26 @@ def add_wind_gust(self, wind_gust_x, wind_gust_y): # Reset wind heading and velocity magnitude self.wind_heading = Function( - lambda h: (180 / np.pi) - * np.arctan2( - self.wind_velocity_x.get_value_opt(h), - self.wind_velocity_y.get_value_opt(h), - ) - % 360, + lambda h: ( + (180 / np.pi) + * np.arctan2( + self.wind_velocity_x.get_value_opt(h), + self.wind_velocity_y.get_value_opt(h), + ) + % 360 + ), "Height (m)", "Wind Heading (degrees)", extrapolation="constant", ) self.wind_speed = Function( lambda h: ( - self.wind_velocity_x.get_value_opt(h) ** 2 - + self.wind_velocity_y.get_value_opt(h) ** 2 - ) - ** 0.5, + ( + self.wind_velocity_x.get_value_opt(h) ** 2 + + self.wind_velocity_y.get_value_opt(h) ** 2 + ) + ** 0.5 + ), "Height (m)", "Wind Speed (m/s)", extrapolation="constant", diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py index 4e6ea8e95..5ad91062c 100644 --- a/rocketpy/rocket/aero_surface/nose_cone.py +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -237,10 +237,16 @@ def theta(x): return np.arccos(1 - 2 * max(min(x / self.length, 1), 0)) self.y_nosecone = Function( - lambda x: self.base_radius - * (theta(x) - np.sin(2 * theta(x)) / 2 + (np.sin(theta(x)) ** 3) / 3) - ** (0.5) - / (np.pi**0.5) + lambda x: ( + self.base_radius + * ( + theta(x) + - np.sin(2 * theta(x)) / 2 + + (np.sin(theta(x)) ** 3) / 3 + ) + ** (0.5) + / (np.pi**0.5) + ) ) elif value in ["tangent", "tangentogive", "ogive"]: @@ -253,15 +259,19 @@ def theta(x): area = np.pi * self.base_radius**2 self.k = 1 - volume / (area * self.length) self.y_nosecone = Function( - lambda x: np.sqrt(rho**2 - (min(x - self.length, 0)) ** 2) - + (self.base_radius - rho) + lambda x: ( + np.sqrt(rho**2 - (min(x - self.length, 0)) ** 2) + + (self.base_radius - rho) + ) ) elif value == "elliptical": self.k = 1 / 3 self.y_nosecone = Function( - lambda x: self.base_radius - * np.sqrt(1 - ((x - self.length) / self.length) ** 2) + lambda x: ( + self.base_radius + * np.sqrt(1 - ((x - self.length) / self.length) ** 2) + ) ) elif value == "vonkarman": @@ -271,15 +281,19 @@ def theta(x): return np.arccos(1 - 2 * max(min(x / self.length, 1), 0)) self.y_nosecone = Function( - lambda x: self.base_radius - * (theta(x) - np.sin(2 * theta(x)) / 2) ** (0.5) - / (np.pi**0.5) + lambda x: ( + self.base_radius + * (theta(x) - np.sin(2 * theta(x)) / 2) ** (0.5) + / (np.pi**0.5) + ) ) elif value == "parabolic": self.k = 0.5 self.y_nosecone = Function( - lambda x: self.base_radius - * ((2 * x / self.length - (x / self.length) ** 2) / (2 - 1)) + lambda x: ( + self.base_radius + * ((2 * x / self.length - (x / self.length) ** 2) / (2 - 1)) + ) ) elif value == "powerseries": self.k = (2 * self.power) / ((2 * self.power) + 1) diff --git a/rocketpy/rocket/aero_surface/tail.py b/rocketpy/rocket/aero_surface/tail.py index b5e4b878a..3e738f99c 100644 --- a/rocketpy/rocket/aero_surface/tail.py +++ b/rocketpy/rocket/aero_surface/tail.py @@ -164,10 +164,12 @@ def evaluate_lift_coefficient(self): """ # Calculate clalpha self.clalpha = Function( - lambda mach: 2 - * ( - (self.bottom_radius / self.rocket_radius) ** 2 - - (self.top_radius / self.rocket_radius) ** 2 + lambda mach: ( + 2 + * ( + (self.bottom_radius / self.rocket_radius) ** 2 + - (self.top_radius / self.rocket_radius) ** 2 + ) ), "Mach", f"Lift coefficient derivative for {self.name}", diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index e27216e26..a080dadd1 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -211,9 +211,10 @@ def __init__( ) alpha, beta = self.noise_corr - self.noise_function = lambda: alpha * self.noise_signal[-1][ - 1 - ] + beta * np.random.normal(noise[0], noise[1]) + self.noise_function = lambda: ( + alpha * self.noise_signal[-1][1] + + beta * np.random.normal(noise[0], noise[1]) + ) self.prints = _ParachutePrints(self) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 1112a98f3..6fe41a145 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -625,12 +625,14 @@ def evaluate_stability_margin(self): self.stability_margin.set_source( lambda mach, time: ( ( - self.center_of_mass.get_value_opt(time) - - self.cp_position.get_value_opt(mach) + ( + self.center_of_mass.get_value_opt(time) + - self.cp_position.get_value_opt(mach) + ) + / (2 * self.radius) ) - / (2 * self.radius) + * self._csys ) - * self._csys ) return self.stability_margin @@ -647,10 +649,12 @@ def evaluate_static_margin(self): # Calculate static margin self.static_margin.set_source( lambda time: ( - self.center_of_mass.get_value_opt(time) - - self.cp_position.get_value_opt(0) + ( + self.center_of_mass.get_value_opt(time) + - self.cp_position.get_value_opt(0) + ) + / (2 * self.radius) ) - / (2 * self.radius) ) # Change sign if coordinate system is upside down self.static_margin *= self._csys diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index ebe5914d5..cb2ccdfa0 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -772,11 +772,12 @@ def __simulate(self, verbose): lambda self, parachute_porosity=parachute.porosity: setattr( self, "parachute_porosity", parachute_porosity ), - lambda self, - added_mass_coefficient=parachute.added_mass_coefficient: setattr( - self, - "parachute_added_mass_coefficient", - added_mass_coefficient, + lambda self, added_mass_coefficient=parachute.added_mass_coefficient: ( + setattr( + self, + "parachute_added_mass_coefficient", + added_mass_coefficient, + ) ), ] self.flight_phases.add_phase( @@ -1020,33 +1021,40 @@ def __simulate(self, verbose): i += 1 # Create flight phase for time after inflation callbacks = [ - lambda self, - parachute_cd_s=parachute.cd_s: setattr( - self, "parachute_cd_s", parachute_cd_s + lambda self, parachute_cd_s=parachute.cd_s: ( + setattr( + self, + "parachute_cd_s", + parachute_cd_s, + ) ), - lambda self, - parachute_radius=parachute.radius: setattr( - self, - "parachute_radius", - parachute_radius, + lambda self, parachute_radius=parachute.radius: ( + setattr( + self, + "parachute_radius", + parachute_radius, + ) ), - lambda self, - parachute_height=parachute.height: setattr( - self, - "parachute_height", - parachute_height, + lambda self, parachute_height=parachute.height: ( + setattr( + self, + "parachute_height", + parachute_height, + ) ), - lambda self, - parachute_porosity=parachute.porosity: setattr( - self, - "parachute_porosity", - parachute_porosity, + lambda self, parachute_porosity=parachute.porosity: ( + setattr( + self, + "parachute_porosity", + parachute_porosity, + ) ), - lambda self, - added_mass_coefficient=parachute.added_mass_coefficient: setattr( - self, - "parachute_added_mass_coefficient", - added_mass_coefficient, + lambda self, added_mass_coefficient=parachute.added_mass_coefficient: ( + setattr( + self, + "parachute_added_mass_coefficient", + added_mass_coefficient, + ) ), ] self.flight_phases.add_phase( From 1751da5a7d85cdd120b56e62722257ff19ca74bb Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 2/9] Add tvc classes --- rocketpy/prints/tvc_prints.py | 41 ++++++++ rocketpy/rocket/tvc.py | 177 ++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 rocketpy/prints/tvc_prints.py create mode 100644 rocketpy/rocket/tvc.py diff --git a/rocketpy/prints/tvc_prints.py b/rocketpy/prints/tvc_prints.py new file mode 100644 index 000000000..c5b16b563 --- /dev/null +++ b/rocketpy/prints/tvc_prints.py @@ -0,0 +1,41 @@ +from inspect import getsourcelines + + +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.gimbal_range:.6f} rad " + f"({self.gimbal_range:.2f}°)" + ) + print( + f"Current Gimbal Angle X: {self.gimbal_angle_x:.6f} rad " + f"({self.gimbal_angle_x:.2f}°)" + ) + print( + f"Current Gimbal Angle Y: {self.gimbal_angle_y:.6f} rad " + f"({self.gimbal_angle_y:.2f}°)" + ) + print(f"Angle Clamping: {'Enabled' if self.clamp else 'Disabled'}") + + def all(self): + """Prints all information of the TVC system.""" + self.geometry() diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py new file mode 100644 index 000000000..a8ae8f862 --- /dev/null +++ b/rocketpy/rocket/tvc.py @@ -0,0 +1,177 @@ +import warnings + +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.tvc_prints import _TVCPrints + +from .aero_surface.aero_surface import AeroSurface + + +class TVC(AeroSurface): + """Thrust Vector Control (TVC) system class. Inherits from AeroSurface. + + 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. + + Attributes + ---------- + TVC.gimbal_angle_x : float + Current gimbal angle around the x-axis (pitch), in degrees. + Positive values deflect the thrust vector in the positive y direction. + TVC.gimbal_angle_y : float + Current gimbal angle around the y-axis (yaw), in degrees. + Positive values deflect the thrust vector in the positive x direction. + 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. + """ + + def __init__( + self, + gimbal_range=0, + clamp=True, + gimbal_angle_x=0.0, + gimbal_angle_y=0.0, + name="TVC", + ): + """Initializes the TVC class. + + Parameters + ---------- + 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 + positive. + 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). + name : str, optional + Name of the TVC system. Default is "TVC". + + Returns + ------- + None + """ + super().__init__(name, 0, None) + self.gimbal_range = gimbal_range + self.clamp = clamp + self.initial_gimbal_angle_x = gimbal_angle_x + self.initial_gimbal_angle_y = gimbal_angle_y + self.gimbal_angle_x = gimbal_angle_x + self.gimbal_angle_y = gimbal_angle_y + self.prints = _TVCPrints(self) + + @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, + ) + self._gimbal_angle_x = value + + @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, + ) + self._gimbal_angle_y = 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] + + 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 + + def info(self): + """Prints summarized information of the TVC system. + + Returns + ------- + None + """ + self.prints.geometry() + + def all_info(self): + """Prints all information of the TVC system. + + Returns + ------- + None + """ + self.info() + + def to_dict(self, **kwargs): # pylint: disable=unused-argument + return { + "gimbal_range": self.gimbal_range, + "clamp": self.clamp, + "gimbal_angle_x": self.initial_gimbal_angle_x, + "gimbal_angle_y": self.initial_gimbal_angle_y, + "name": self.name, + } + + @classmethod + def from_dict(cls, data): + return cls( + gimbal_range=data.get("gimbal_range"), + clamp=data.get("clamp"), + gimbal_angle_x=data.get("gimbal_angle_x"), + gimbal_angle_y=data.get("gimbal_angle_y"), + name=data.get("name"), + ) From 4ed0195ba784ac9155aa1c3b57230ce1daeb05fc Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 3/9] Add tvc class to rocket.py --- rocketpy/rocket/rocket.py | 108 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 6fe41a145..92edf060a 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -21,6 +21,7 @@ ) from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +from rocketpy.rocket.tvc import TVC from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute from rocketpy.tools import ( @@ -316,6 +317,7 @@ def __init__( # pylint: disable=too-many-statements self.parachutes = [] self._controllers = [] self.air_brakes = [] + self.tvc = [] self.sensors = Components() self.aerodynamic_surfaces = Components() self.surfaces_cp_to_cdm = {} @@ -1722,6 +1724,112 @@ def add_air_brakes( else: return air_brakes + def add_tvc( + self, + gimbal_range, + controller_function, + sampling_rate, + clamp=True, + initial_observed_variables=None, + return_controller=False, + name="TVC", + controller_name="TVC Controller", + ): + """Creates a new thrust vector control (TVC) system, storing its + parameters such as gimbal angle maximum controller function, and + sampling rate. + + 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 + positive. + 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: + + 1. `time` (float): The current simulation time in seconds. + 2. `sampling_rate` (float): The rate at which the controller + function is called, measured in Hertz (Hz). + 3. `state` (list): The state vector of the simulation, structured as + `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`. + 4. `state_history` (list): A record of the rocket's state at each + step throughout the simulation. The state_history is organized as a + list of lists, with each sublist containing a state vector. The last + item in the list always corresponds to the previous state vector, + providing a chronological sequence of the rocket's evolving states. + 5. `observed_variables` (list): A list containing the variables that + the controller function manages. The initial values in the first + step of the simulation are provided by the + `initial_observed_variables` argument. + 6. `interactive_objects` (list): A list containing the TVC object + that the controller function can interact with. + 7. `sensors` (list): A list of sensors that are attached to the + rocket. The most recent measurements of the sensors are provided + with the ``sensor.measurement`` attribute. The sensors are + listed in the same order as they are added to the rocket + ``interactive_objects`` + + This function will be called during the simulation at the specified + sampling rate. The function should evaluate and change the observed + objects as needed. The function should return None. + + .. note:: + + 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. + clamp : bool, optional + If True, the simulation will clamp gimbal angles to the range + [-gimbal_range, gimbal_range]. If False, a warning is + issued when gimbal angles exceed the range. Default is True. + 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 + `observed_variables` argument of the controller function. The + default value is None, which initializes the list as an empty list. + return_controller : bool, optional + If True, the function will return the controller object created. + Default is False. + 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". + 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". + + Returns + ------- + tvc : TVC + TVC object created. + controller : Controller, optional + Controller object created (only if return_controller is True). + """ + tvc = TVC( + gimbal_range=gimbal_range, + clamp=clamp, + gimbal_angle_x=0, + gimbal_angle_y=0, + name=name, + ) + _controller = _Controller( + interactive_objects=tvc, + controller_function=controller_function, + sampling_rate=sampling_rate, + initial_observed_variables=initial_observed_variables, + name=controller_name, + ) + self.tvc.append(tvc) + self._add_controllers(_controller) + if return_controller: + return tvc, _controller + else: + return tvc + def set_rail_buttons( self, upper_button_position, From 261e6e54357379d49407fba55671cce4c7118cb1 Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 4/9] Fix: only one tvc per rocket --- rocketpy/rocket/rocket.py | 10 ++++++++-- rocketpy/rocket/tvc.py | 10 ++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 92edf060a..d3df491da 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -317,7 +317,6 @@ def __init__( # pylint: disable=too-many-statements self.parachutes = [] self._controllers = [] self.air_brakes = [] - self.tvc = [] self.sensors = Components() self.aerodynamic_surfaces = Components() self.surfaces_cp_to_cdm = {} @@ -1809,6 +1808,13 @@ def add_tvc( controller : Controller, optional Controller object created (only if return_controller is True). """ + if hasattr(self, "tvc"): + # pylint: disable=access-member-before-definition + print( + "Only one TVC per rocket is currently supported. " + + "Overwriting previous TVC." + ) + tvc = TVC( gimbal_range=gimbal_range, clamp=clamp, @@ -1823,7 +1829,7 @@ def add_tvc( initial_observed_variables=initial_observed_variables, name=controller_name, ) - self.tvc.append(tvc) + self.tvc = tvc self._add_controllers(_controller) if return_controller: return tvc, _controller diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py index a8ae8f862..316b349f7 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/tvc.py @@ -2,14 +2,9 @@ import numpy as np -from rocketpy.mathutils.function import Function -from rocketpy.mathutils.vector_matrix import Vector -from rocketpy.tvc_prints import _TVCPrints +from ..prints.tvc_prints import _TVCPrints -from .aero_surface.aero_surface import AeroSurface - - -class TVC(AeroSurface): +class TVC(): """Thrust Vector Control (TVC) system class. Inherits from AeroSurface. This class represents a thrust vector control system that allows deflection @@ -68,7 +63,6 @@ def __init__( ------- None """ - super().__init__(name, 0, None) self.gimbal_range = gimbal_range self.clamp = clamp self.initial_gimbal_angle_x = gimbal_angle_x From e16346d664f247e91a553679a6d332e0e7422cae Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 5/9] Add TVC dynamics to flight.py --- rocketpy/rocket/tvc.py | 4 ++-- rocketpy/simulation/flight.py | 42 ++++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py index 316b349f7..cdfae08e5 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/tvc.py @@ -15,10 +15,10 @@ class TVC(): ---------- TVC.gimbal_angle_x : float Current gimbal angle around the x-axis (pitch), in degrees. - Positive values deflect the thrust vector in the positive y direction. + Positive values provides positive M1 (pitch moment). TVC.gimbal_angle_y : float Current gimbal angle around the y-axis (yaw), in degrees. - Positive values deflect the thrust vector in the positive x direction. + 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. diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index cb2ccdfa0..181483190 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1252,9 +1252,11 @@ def __init_controllers(self): "time_overshoot has been set to False due to the presence " "of controllers or sensors. " ) - # reset controllable object to initial state (only airbrakes for now) + # reset controllable objects to initial state (air brakes and TVC) for air_brakes in self.rocket.air_brakes: air_brakes._reset() + if hasattr(self.rocket, "tvc"): + self.rocket.tvc._reset() self.sensor_data = {} for sensor in self.sensors: @@ -1498,9 +1500,22 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals + self.rocket.motor.pressure_thrust(pressure), 0, ) + + # TVC (Thrust Vector Control) + if hasattr(self.rocket, "tvc"): + # TVC Fz thrust: F = T * sqrt(1 - sin(gimbal_angle_x)**2 - sin(gimbal_angle_y)**2) + thrust3 = net_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) + tvc_lever = self.rocket.center_of_mass(t) - self.rocket.nozzle_position + # TVC Mx My moments: M = T * sin(x) * r + M1 += np.sin(self.rocket.tvc.gimbal_angle_x*(np.pi / 180)) * net_thrust * tvc_lever + M2 += np.sin(self.rocket.tvc.gimbal_angle_y*(np.pi / 180)) * net_thrust * tvc_lever + else: + thrust3 = net_thrust # Off center moment - M1 += self.rocket.thrust_eccentricity_y * net_thrust - M2 -= self.rocket.thrust_eccentricity_x * net_thrust + M1 += self.rocket.thrust_eccentricity_y * thrust3 + M2 -= self.rocket.thrust_eccentricity_x * thrust3 + else: # Motor stopped # Inertias @@ -1513,7 +1528,7 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals # Mass mass_flow_rate_at_t, propellant_mass_at_t = 0, 0 # thrust - net_thrust = 0 + thrust3 = 0 # Retrieve important quantities # Inertias @@ -1716,7 +1731,7 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals + 2 * c * mass_flow_rate_at_t * omega1 ) / total_mass_at_t, - (R3 - b * propellant_mass_at_t * (alpha2 - omega1 * omega3) + net_thrust) + (R3 - b * propellant_mass_at_t * (alpha2 - omega1 * omega3) + thrust3) / total_mass_at_t, ] ax, ay, az = K @ Vector(L) @@ -1910,14 +1925,25 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too M2 += N M3 += L + # TVC (Thrust Vector Control) + if hasattr(self.rocket, "tvc"): + tvc_lever = self.rocket.center_of_mass(t) - self.rocket.nozzle_position + # TVC Mx My moments: M = T * sin(x) * r + M1 += np.sin(self.rocket.tvc.gimbal_angle_x*(np.pi / 180)) * net_thrust * tvc_lever + M2 += np.sin(self.rocket.tvc.gimbal_angle_y*(np.pi / 180)) * net_thrust * tvc_lever + # TVC Fz thrust: F = T * sqrt(1 - sin^2(x) - sin^2(y)) + thrust3 = net_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)) + else: + thrust3 = net_thrust # Off center moment M1 += ( self.rocket.cp_eccentricity_y * R3 - + self.rocket.thrust_eccentricity_y * net_thrust + + self.rocket.thrust_eccentricity_y * thrust3 ) M2 -= ( self.rocket.cp_eccentricity_x * R3 - + self.rocket.thrust_eccentricity_x * net_thrust + + self.rocket.thrust_eccentricity_x * thrust3 ) M3 += self.rocket.cp_eccentricity_x * R2 - self.rocket.cp_eccentricity_y * R1 @@ -1928,7 +1954,7 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too T00 = total_mass * r_CM T03 = 2 * total_mass_dot * (r_NOZ - r_CM) - 2 * total_mass * r_CM_dot T04 = ( - Vector([0, 0, net_thrust]) + Vector([0, 0, thrust3]) - total_mass * r_CM_ddot - 2 * total_mass_dot * r_CM_dot + total_mass_ddot * (r_NOZ - r_CM) From eb50069bcf2bd62d4e6bc9d6a955d81772d639c5 Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 6/9] Fix tvc prints --- rocketpy/prints/tvc_prints.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/rocketpy/prints/tvc_prints.py b/rocketpy/prints/tvc_prints.py index c5b16b563..03ed6acda 100644 --- a/rocketpy/prints/tvc_prints.py +++ b/rocketpy/prints/tvc_prints.py @@ -1,6 +1,3 @@ -from inspect import getsourcelines - - class _TVCPrints: """Class that contains all TVC prints.""" @@ -22,19 +19,10 @@ def geometry(self): """Prints geometric information of the TVC system.""" print("Geometric information of the TVC System:") print("----------------------------------") - print( - f"Maximum Gimbal Angle: {self.gimbal_range:.6f} rad " - f"({self.gimbal_range:.2f}°)" - ) - print( - f"Current Gimbal Angle X: {self.gimbal_angle_x:.6f} rad " - f"({self.gimbal_angle_x:.2f}°)" - ) - print( - f"Current Gimbal Angle Y: {self.gimbal_angle_y:.6f} rad " - f"({self.gimbal_angle_y:.2f}°)" - ) - print(f"Angle Clamping: {'Enabled' if self.clamp else 'Disabled'}") + 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.""" From f20915de5a65d16039b7349409c540040e71a2c2 Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 7/9] Fix tvc class init and remove old tvc controllers when add more than one TVC --- rocketpy/rocket/rocket.py | 3 ++- rocketpy/rocket/tvc.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index d3df491da..5cf3324f3 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1812,8 +1812,9 @@ def add_tvc( # pylint: disable=access-member-before-definition print( "Only one TVC per rocket is currently supported. " - + "Overwriting previous TVC." + + "Overwriting previous TVC and controllers." ) + self._controllers = [controller for controller in self._controllers if not isinstance(controller.interactive_objects, TVC)] tvc = TVC( gimbal_range=gimbal_range, diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py index cdfae08e5..ec6f68462 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/tvc.py @@ -63,6 +63,7 @@ def __init__( ------- None """ + self.name = name self.gimbal_range = gimbal_range self.clamp = clamp self.initial_gimbal_angle_x = gimbal_angle_x From c47f13457c59fd87ae4a5c776464a3aae9a6cad8 Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 8/9] Run black formatter --- rocketpy/rocket/rocket.py | 8 +++++-- rocketpy/rocket/tvc.py | 7 +++--- rocketpy/simulation/flight.py | 40 ++++++++++++++++++++++++++++------- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 5cf3324f3..c603e6b88 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -21,9 +21,9 @@ ) from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins from rocketpy.rocket.aero_surface.generic_surface import GenericSurface -from rocketpy.rocket.tvc import TVC from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute +from rocketpy.rocket.tvc import TVC from rocketpy.tools import ( deprecated, find_obj_from_hash, @@ -1814,7 +1814,11 @@ def add_tvc( "Only one TVC per rocket is currently supported. " + "Overwriting previous TVC and controllers." ) - self._controllers = [controller for controller in self._controllers if not isinstance(controller.interactive_objects, TVC)] + self._controllers = [ + controller + for controller in self._controllers + if not isinstance(controller.interactive_objects, TVC) + ] tvc = TVC( gimbal_range=gimbal_range, diff --git a/rocketpy/rocket/tvc.py b/rocketpy/rocket/tvc.py index ec6f68462..ccd8728da 100644 --- a/rocketpy/rocket/tvc.py +++ b/rocketpy/rocket/tvc.py @@ -4,7 +4,8 @@ from ..prints.tvc_prints import _TVCPrints -class TVC(): + +class TVC: """Thrust Vector Control (TVC) system class. Inherits from AeroSurface. This class represents a thrust vector control system that allows deflection @@ -128,8 +129,8 @@ def gimbal_angles(self, value): 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 + """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 diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 181483190..72a649157 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1504,12 +1504,23 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals # TVC (Thrust Vector Control) if hasattr(self.rocket, "tvc"): # TVC Fz thrust: F = T * sqrt(1 - sin(gimbal_angle_x)**2 - sin(gimbal_angle_y)**2) - thrust3 = net_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) + thrust3 = net_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 + ) tvc_lever = self.rocket.center_of_mass(t) - self.rocket.nozzle_position # TVC Mx My moments: M = T * sin(x) * r - M1 += np.sin(self.rocket.tvc.gimbal_angle_x*(np.pi / 180)) * net_thrust * tvc_lever - M2 += np.sin(self.rocket.tvc.gimbal_angle_y*(np.pi / 180)) * net_thrust * tvc_lever + M1 += ( + np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) + * net_thrust + * tvc_lever + ) + M2 += ( + np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) + * net_thrust + * tvc_lever + ) else: thrust3 = net_thrust # Off center moment @@ -1929,11 +1940,24 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too if hasattr(self.rocket, "tvc"): tvc_lever = self.rocket.center_of_mass(t) - self.rocket.nozzle_position # TVC Mx My moments: M = T * sin(x) * r - M1 += np.sin(self.rocket.tvc.gimbal_angle_x*(np.pi / 180)) * net_thrust * tvc_lever - M2 += np.sin(self.rocket.tvc.gimbal_angle_y*(np.pi / 180)) * net_thrust * tvc_lever + M1 += ( + np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) + * net_thrust + * tvc_lever + ) + M2 += ( + np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) + * net_thrust + * tvc_lever + ) # TVC Fz thrust: F = T * sqrt(1 - sin^2(x) - sin^2(y)) - thrust3 = net_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)) + thrust3 = net_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 + ) + ) else: thrust3 = net_thrust # Off center moment From 8bc560922057268f5d58815e65f940435a863b6a Mon Sep 17 00:00:00 2001 From: ZuoRen Chen Date: Tue, 17 Mar 2026 23:45:55 +0000 Subject: [PATCH 9/9] Fix: TVC torque act on CDM, not CM --- rocketpy/simulation/flight.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 72a649157..2d420c3d1 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1509,7 +1509,7 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals - np.sin(self.rocket.tvc.gimbal_angle_x * (np.pi / 180)) ** 2 - np.sin(self.rocket.tvc.gimbal_angle_y * (np.pi / 180)) ** 2 ) - tvc_lever = self.rocket.center_of_mass(t) - self.rocket.nozzle_position + 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)) @@ -1938,7 +1938,7 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too # TVC (Thrust Vector Control) if hasattr(self.rocket, "tvc"): - tvc_lever = self.rocket.center_of_mass(t) - self.rocket.nozzle_position + 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))