Skip to content

Commit 39431d1

Browse files
Rafit345Gui-FernandesBR
authored andcommitted
"ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis)"
Refs #28. This commit was made as a submission to the selective process deliverables challenge. The method udot_rail2 functions as an intermediate flight phase before the rocket has fully left the guide rail, allowing for 3 degrees of freedom (linear motion along the rail, pitch and yaw). Flight init includes a feature to run a simulation without udot_rail2. Numerical values enabling udot_rail2 are very close to 1 DOF flight. Flight phase transitions smoothly from 1 DOF rail phase to 3DOF and from 3 DOF to 6 DOF free flight. Current equations of motion inside udot_rail2 rely heavily on udot_generalized, ensuring 3 DOF through vector operations. Still working on the implementation of proper lagrangean expansion /derivation of equations of motion. Articles "Tip-off effect analysis of a vehicle moving along an inclined guideway by considering dynamic interactions" by Chou et al and "ANALYSIS OF MISSILE LAUNCHERS PART Q Tipoff Effects in Helical Rail Launchers" by Hosken et al are proving useful. --Summary-- Add preliminary udot_rail2 (3-DOF tip-off) support and safe, deterministic phase-insertion handling during rail → 6DOF transitions. Add a feature flag to enable/disable udot_rail2 on Flight init. Add a Hermite-root fallback to avoid hard failures when rail-exit root filtering returns no valid root (warn + midpoint fallback). Add comprehensive unit tests (alignment, no-roll, insertion-order, CSV comparisons) and sample CSV output for comparison runs with udot_rail2 enabled vs disabled.
1 parent 9ccc804 commit 39431d1

2 files changed

Lines changed: 477 additions & 4 deletions

File tree

rocketpy/simulation/flight.py

Lines changed: 265 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements
504504
verbose=False,
505505
name="Flight",
506506
equations_of_motion="standard",
507+
use_udot_rail2=True,
507508
ode_solver="LSODA",
508509
simulation_mode="6 DOF",
509510
):
@@ -581,6 +582,12 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements
581582
more restricted set of equations of motion that only works for
582583
solid propulsion rockets. Such equations were used in RocketPy v0
583584
and are kept here for backwards compatibility.
585+
use_udot_rail2 : bool, optional
586+
If True, enable the intermediate 3-DOF rail phase ``udot_rail2``
587+
(tip-off analysis). If False, the flight remains in the 1-DOF
588+
``udot_rail1`` phase until the upper rail button exit and then
589+
transitions directly to the generalized 6-DOF dynamics, as in
590+
previous versions. Default is True.
584591
ode_solver : str, ``scipy.integrate.OdeSolver``, optional
585592
Integration method to use to solve the equations of motion ODE.
586593
Available options are: 'RK23', 'RK45', 'DOP853', 'Radau', 'BDF',
@@ -626,6 +633,8 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements
626633
self.equations_of_motion = equations_of_motion
627634
self.simulation_mode = simulation_mode
628635
self.ode_solver = ode_solver
636+
# Enable or disable the intermediate 3-DOF rail phase
637+
self.use_udot_rail2 = use_udot_rail2
629638

630639
# Controller initialization
631640
self.__init_controllers()
@@ -1031,8 +1040,19 @@ def __check_simulation_events(self, phase, phase_index, node_index):
10311040
bool
10321041
True if an event occurred and the simulation should break.
10331042
"""
1043+
# Check for the first time the rocket is between the two rail buttons
1044+
# (tip-off analysis). Optionally inserts the intermediate 3-DOF
1045+
# `udot_rail2` phase; see __handle_between_rails_event.
1046+
if len(self.between_rails_state) == 1 and (
1047+
self.y_sol[0] ** 2
1048+
+ self.y_sol[1] ** 2
1049+
+ (self.y_sol[2] - self.env.elevation) ** 2
1050+
>= self.effective_2rl**2
1051+
):
1052+
if self.__handle_between_rails_event(phase, phase_index, node_index):
1053+
return True
10341054
# Check for first out of rail event
1035-
if len(self.out_of_rail_state) == 1 and (
1055+
elif len(self.out_of_rail_state) == 1 and (
10361056
self.y_sol[0] ** 2
10371057
+ self.y_sol[1] ** 2
10381058
+ (self.y_sol[2] - self.env.elevation) ** 2
@@ -1127,6 +1147,47 @@ def __handle_out_of_rail_event(self, phase, phase_index, node_index):
11271147
phase.solver.status = "finished"
11281148
return True
11291149

1150+
def __handle_between_rails_event(self, phase, phase_index, node_index):
1151+
"""Handle the intermediate rail phase (tip-off analysis).
1152+
1153+
Records the state at which the rocket first reaches ``effective_2rl``
1154+
and, when ``use_udot_rail2`` is enabled, inserts the 3-DOF
1155+
``udot_rail2`` flight phase.
1156+
1157+
Parameters
1158+
----------
1159+
phase : FlightPhase
1160+
The current flight phase.
1161+
phase_index : int
1162+
The index of the current phase.
1163+
node_index : int
1164+
The index of the current node.
1165+
1166+
Returns
1167+
-------
1168+
bool
1169+
True if a new flight phase was inserted (simulation should break),
1170+
False otherwise.
1171+
"""
1172+
self.between_rails_time = self.t
1173+
self.between_rails_time_index = len(self.solution) - 1
1174+
self.between_rails_state = self.y_sol
1175+
# Optionally insert the udot_rail2 3-DOF phase. If disabled, the solver
1176+
# remains in the current phase and will transition to generalized
1177+
# dynamics at the upper button exit as before.
1178+
if self.use_udot_rail2:
1179+
self.flight_phases.add_phase(
1180+
self.t,
1181+
self.udot_rail2,
1182+
index=phase_index + 1,
1183+
)
1184+
# Prepare to leave loops and start new flight phase
1185+
phase.time_nodes.flush_after(node_index)
1186+
phase.time_nodes.add_node(self.t, [], [], [])
1187+
phase.solver.status = "finished"
1188+
return True
1189+
return False
1190+
11301191
def __handle_apogee_event(self, phase, phase_index, node_index):
11311192
"""Handle the apogee event.
11321193
@@ -1535,6 +1596,9 @@ def __init_solution_monitors(self):
15351596
self.out_of_rail_time = 0
15361597
self.out_of_rail_time_index = 0
15371598
self.out_of_rail_state = np.array([0])
1599+
self.between_rails_state = np.array([0])
1600+
self.between_rails_time = 0
1601+
self.between_rails_time_index = 0
15381602
self.apogee_state = np.array([0])
15391603
self.apogee = 0
15401604
self.apogee_time = 0
@@ -1576,6 +1640,18 @@ def __init_flight_state(self):
15761640
e0_init, e1_init, e2_init, e3_init = euler313_to_quaternions(
15771641
self.phi_init, self.theta_init, self.psi_init
15781642
)
1643+
1644+
K_init = Matrix.transformation([e0_init, e1_init, e2_init, e3_init])
1645+
1646+
# Body axis pointing along rocket symmetry (body z-axis)
1647+
body_axis = Vector([0, 0, 1])
1648+
1649+
# Attitude vector in inertial frame
1650+
attitude_vec = K_init @ body_axis
1651+
1652+
# Unit vector (normalize)
1653+
self.attitude_unit = attitude_vec / abs(attitude_vec)
1654+
15791655
# Store initial conditions
15801656
self.initial_solution = [
15811657
self.t_initial,
@@ -1603,6 +1679,10 @@ def __init_flight_state(self):
16031679
self.out_of_rail_state = self.initial_solution[1:]
16041680
self.out_of_rail_time = self.initial_solution[0]
16051681
self.out_of_rail_time_index = 0
1682+
# save out of rail 2 state and time with the same data as out of rail
1683+
self.between_rails_state = self.initial_solution[1:]
1684+
self.between_rails_time = self.initial_solution[0]
1685+
self.between_rails_time_index = 0
16061686
# Set initial derivative for 6-DOF flight phase
16071687
self.initial_derivative = self.u_dot_generalized
16081688
else:
@@ -1611,6 +1691,10 @@ def __init_flight_state(self):
16111691
self.out_of_rail_state = self.initial_solution[1:]
16121692
self.out_of_rail_time = self.initial_solution[0]
16131693
self.out_of_rail_time_index = 0
1694+
# save out of rail 2 state and time with the same data as out of rail
1695+
self.between_rails_state = self.initial_solution[1:]
1696+
self.between_rails_time = self.initial_solution[0]
1697+
self.between_rails_time_index = 0
16141698
self.t_initial = self.initial_solution[0]
16151699
self.initial_derivative = self.u_dot_generalized
16161700
if self._controllers or self.sensors:
@@ -1879,7 +1963,7 @@ def udot_rail1(self, t, u, post_processing=False):
18791963
return [vx, vy, vz, ax, ay, az, 0, 0, 0, 0, 0, 0, 0]
18801964

18811965
def udot_rail2(self, t, u, post_processing=False): # pragma: no cover
1882-
"""[Still not implemented] Calculates derivative of u state vector with
1966+
"""[WIP] Calculates derivative of u state vector with
18831967
respect to time when rocket is flying in 3 DOF motion in the rail.
18841968
18851969
Parameters
@@ -1899,8 +1983,184 @@ def udot_rail2(self, t, u, post_processing=False): # pragma: no cover
18991983
State vector defined by u_dot = [vx, vy, vz, ax, ay, az,
19001984
e0dot, e1dot, e2dot, e3dot, alpha1, alpha2, alpha3].
19011985
"""
1902-
# Hey! We will finish this function later, now we just can use u_dot
1903-
return self.u_dot_generalized(t, u, post_processing=post_processing)
1986+
1987+
# Retrieve integration data
1988+
_, _, z, vx, vy, vz, e0, e1, e2, e3, omega1, omega2, omega3 = u
1989+
1990+
# Create necessary vectors
1991+
# r = Vector([x, y, z]) # CDM position vector
1992+
v = Vector([vx, vy, vz]) # CDM velocity vector
1993+
e = [e0, e1, e2, e3] # Euler parameters/quaternions
1994+
w = Vector([omega1, omega2, omega3]) # Angular velocity vector
1995+
1996+
# Retrieve necessary quantities
1997+
## Rocket mass
1998+
total_mass = self.rocket.total_mass.get_value_opt(t)
1999+
total_mass_dot = self.rocket.total_mass_flow_rate.get_value_opt(t)
2000+
total_mass_ddot = self.rocket.total_mass_flow_rate.differentiate_complex_step(t)
2001+
## CM position vector and time derivatives relative to CDM in body frame
2002+
r_CM_z = self.rocket.com_to_cdm_function
2003+
r_CM_t = r_CM_z.get_value_opt(t)
2004+
r_CM = Vector([0, 0, r_CM_t])
2005+
r_CM_dot = Vector([0, 0, r_CM_z.differentiate_complex_step(t)])
2006+
r_CM_ddot = Vector([0, 0, r_CM_z.differentiate(t, order=2)])
2007+
## Nozzle position vector
2008+
r_NOZ = Vector([0, 0, self.rocket.nozzle_to_cdm])
2009+
## Nozzle gyration tensor
2010+
S_nozzle = self.rocket.nozzle_gyration_tensor
2011+
## Inertia tensor
2012+
inertia_tensor = self.rocket.get_inertia_tensor_at_time(t)
2013+
## Inertia tensor time derivative in the body frame
2014+
I_dot = self.rocket.get_inertia_tensor_derivative_at_time(t)
2015+
2016+
# Calculate the Inertia tensor relative to CM
2017+
H = (r_CM.cross_matrix @ -r_CM.cross_matrix) * total_mass
2018+
I_CM = inertia_tensor - H
2019+
2020+
# Prepare transformation matrices
2021+
K = Matrix.transformation(e)
2022+
Kt = K.transpose
2023+
2024+
# Compute aerodynamic forces and moments
2025+
R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0
2026+
2027+
## Drag force
2028+
rho = self.env.density.get_value_opt(z)
2029+
wind_velocity_x = self.env.wind_velocity_x.get_value_opt(z)
2030+
wind_velocity_y = self.env.wind_velocity_y.get_value_opt(z)
2031+
wind_velocity = Vector([wind_velocity_x, wind_velocity_y, 0])
2032+
free_stream_speed = abs((wind_velocity - Vector(v)))
2033+
speed_of_sound = self.env.speed_of_sound.get_value_opt(z)
2034+
free_stream_mach = free_stream_speed / speed_of_sound
2035+
2036+
if self.rocket.motor.burn_start_time < t < self.rocket.motor.burn_out_time:
2037+
pressure = self.env.pressure.get_value_opt(z)
2038+
net_thrust = max(
2039+
self.rocket.motor.thrust.get_value_opt(t)
2040+
+ self.rocket.motor.pressure_thrust(pressure),
2041+
0,
2042+
)
2043+
drag_coeff = self.rocket.power_on_drag.get_value_opt(free_stream_mach)
2044+
else:
2045+
net_thrust = 0
2046+
drag_coeff = self.rocket.power_off_drag.get_value_opt(free_stream_mach)
2047+
R3 += -0.5 * rho * (free_stream_speed**2) * self.rocket.area * drag_coeff
2048+
# Get rocket velocity in body frame
2049+
velocity_in_body_frame = Kt @ v
2050+
# Calculate lift and moment for each component of the rocket
2051+
for aero_surface, _ in self.rocket.aerodynamic_surfaces:
2052+
# Component cp relative to CDM in body frame
2053+
comp_cp = self.rocket.surfaces_cp_to_cdm[aero_surface]
2054+
# Component absolute velocity in body frame
2055+
comp_vb = velocity_in_body_frame + (w ^ comp_cp)
2056+
# Wind velocity at component altitude
2057+
comp_z = z + (K @ comp_cp).z
2058+
comp_wind_vx = self.env.wind_velocity_x.get_value_opt(comp_z)
2059+
comp_wind_vy = self.env.wind_velocity_y.get_value_opt(comp_z)
2060+
# Component freestream velocity in body frame
2061+
comp_wind_vb = Kt @ Vector([comp_wind_vx, comp_wind_vy, 0])
2062+
comp_stream_velocity = comp_wind_vb - comp_vb
2063+
comp_stream_speed = abs(comp_stream_velocity)
2064+
comp_stream_mach = comp_stream_speed / speed_of_sound
2065+
# Reynolds at component altitude
2066+
# TODO: Reynolds is only used in generic surfaces. This calculation
2067+
# should be moved to the surface class for efficiency
2068+
comp_reynolds = (
2069+
self.env.density.get_value_opt(comp_z)
2070+
* comp_stream_speed
2071+
* aero_surface.reference_length
2072+
/ self.env.dynamic_viscosity.get_value_opt(comp_z)
2073+
)
2074+
# Forces and moments
2075+
X, Y, Z, M, N, L = aero_surface.compute_forces_and_moments(
2076+
comp_stream_velocity,
2077+
comp_stream_speed,
2078+
comp_stream_mach,
2079+
rho,
2080+
comp_cp,
2081+
w,
2082+
comp_reynolds,
2083+
)
2084+
R1 += X
2085+
R2 += Y
2086+
R3 += Z
2087+
M1 += M
2088+
M2 += N
2089+
M3 += L
2090+
2091+
# Off center moment
2092+
M1 += (
2093+
self.rocket.cp_eccentricity_y * R3
2094+
+ self.rocket.thrust_eccentricity_y * net_thrust
2095+
)
2096+
M2 -= (
2097+
self.rocket.cp_eccentricity_x * R3
2098+
+ self.rocket.thrust_eccentricity_x * net_thrust
2099+
)
2100+
M3 += self.rocket.cp_eccentricity_x * R2 - self.rocket.cp_eccentricity_y * R1
2101+
2102+
weight_in_body_frame = Kt @ Vector(
2103+
[0, 0, -total_mass * self.env.gravity.get_value_opt(z)]
2104+
)
2105+
2106+
T00 = total_mass * r_CM
2107+
T03 = 2 * total_mass_dot * (r_NOZ - r_CM) - 2 * total_mass * r_CM_dot
2108+
T04 = (
2109+
Vector([0, 0, net_thrust])
2110+
- total_mass * r_CM_ddot
2111+
- 2 * total_mass_dot * r_CM_dot
2112+
+ total_mass_ddot * (r_NOZ - r_CM)
2113+
)
2114+
T05 = total_mass_dot * S_nozzle - I_dot
2115+
2116+
T20 = (
2117+
((w ^ T00) ^ w)
2118+
+ (w ^ T03)
2119+
+ T04
2120+
+ weight_in_body_frame
2121+
+ Vector([R1, R2, R3])
2122+
)
2123+
2124+
T21 = (
2125+
((inertia_tensor @ w) ^ w)
2126+
+ T05 @ w
2127+
- (weight_in_body_frame ^ r_CM)
2128+
+ Vector([M1, M2, M3])
2129+
)
2130+
2131+
# Angular velocity derivative
2132+
w_dot = I_CM.inverse @ (T21 + (T20 ^ r_CM))
2133+
# Enforce zero roll acceleration for 3-DOF rail motion by creating
2134+
# a new Vector with the third component set to zero instead of
2135+
# attempting item assignment on the Vector type.
2136+
w_dot = Vector([w_dot[0], w_dot[1], 0.0])
2137+
2138+
# Euler parameters derivative
2139+
e_dot = [
2140+
0.5 * (-omega1 * e1 - omega2 * e2), # - omega3 * e3),
2141+
0.5 * (omega1 * e0 - omega2 * e3), # omega3 * e2
2142+
0.5 * (omega2 * e0 + omega1 * e3), # - omega3 * e1
2143+
0.5 * (omega2 * e1 - omega1 * e2), # +omega3 * e0
2144+
]
2145+
2146+
# Velocity vector derivative + Coriolis acceleration
2147+
w_earth = Vector(self.env.earth_rotation_vector)
2148+
v_dot = K @ (T20 / total_mass - (r_CM ^ w_dot)) - 2 * (w_earth ^ v)
2149+
2150+
# Position vector derivative: projection of velocity along the rail
2151+
rail = self.attitude_unit # unit vector inertial frame
2152+
velocity_vec = Vector([vx, vy, vz])
2153+
r_dot = rail * (velocity_vec @ rail)
2154+
2155+
# Create u_dot
2156+
u_dot = [*r_dot, *v_dot, *e_dot, *w_dot]
2157+
2158+
if post_processing:
2159+
self.__post_processed_variables.append(
2160+
[t, *v_dot, *w_dot, R1, R2, R3, M1, M2, M3, net_thrust]
2161+
)
2162+
2163+
return u_dot
19042164

19052165
def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals,too-many-statements
19062166
"""Calculates derivative of u state vector with respect to time
@@ -4212,6 +4472,7 @@ def to_dict(self, **kwargs):
42124472
"time": self.time,
42134473
"out_of_rail_velocity": self.out_of_rail_velocity,
42144474
"out_of_rail_state": self.out_of_rail_state,
4475+
"between_rails_state": self.between_rails_state,
42154476
"apogee_x": self.apogee_x,
42164477
"apogee_y": self.apogee_y,
42174478
"apogee_state": self.apogee_state,

0 commit comments

Comments
 (0)