Skip to content

Commit 45a7369

Browse files
CopilotGui-FernandesBRaZira371
authored
ENH: Add multi-dimensional drag coefficient support (Cd as function of M, Re, α) (#875)
* Add grid interpolation support to Function class with from_grid() method Co-authored-by: Gui-FernandesBR <[email protected]> * Add multi-dimensional drag coefficient support to Flight class and integration tests Co-authored-by: Gui-FernandesBR <[email protected]> * Run ruff format on modified files Co-authored-by: Gui-FernandesBR <[email protected]> * MNt: refactoring get_drag_coefficient in flight.py - MNT: velocity_body was not being used in get_drag_coefficient, removed it as an input * MNT: refactoring in flight.py and lint corrections to function.py and test_multidim_drag.py -MNT: removed unused velocity in body frame parameter requirement from all instances of get_drag_coefficient in flight.py - MNT: corrected docstring for get_value_opt_grid in function.py - MNT: shifted import of classes before the definition of functions in test_multidim_drag.py * MNT: refactoring flight.py to remove unused parameters * MNT: correction of docstring function.py - MNT: rearranged the docstring of from_grid in function.py to match the expected output of doctest * MNT: make format and lint corrections to function.py - MNT: reran make format and lint on function.py to correct after previous changes to from_grid * MNT: pylint adjustments for new methods in function.py - MNT: disables pylint unused private member for get_value_opt_grid as it is called upon dynamically by from_grid - MNT: disabled pylint too many statement for from_grid for now and added a to-do to refactor it into smaller methods/helper functions - MNT: updated .pylintrc to record Re as good name * MNt: make format after previous change to function.py * MNT: removed Re where unused in test_multidim_drag.py - MNT: Re variable was unused in test_3d_drag_with_varying_alpha thus replaced it * TST: Add tests for shepard_fallback in test_function_grid.py (#879) * Add tests for shepard_fallback in test_function_grid.py Co-authored-by: aZira371 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: aZira371 <[email protected]> * TST: test_multidim_drag.py - TST: Added integration-level check to verify the flight simulation actually uses alpha when evaluating multi dim drag coeff. - TST: utilized pytest fixtures where possible. * MNT: addition of is_multidimensional to function.py - MNT: Function.is_multidimensional property in function.py. It returns True when the function's internal domain dimension is greater than 1 and safely returns False on errors. - MNT: Replaced the ad-hoc hasattr/len check in flight.py with a clearer check: if isinstance and drag_function.is_multidimensional * MNT: Added validation in from_grid in function.py to raise a ValueError when an unsupported extrapolation value is provided. * ENH: Added alpha-sensitive flight fixtures to flight_fixtures.py ENH: Used shared flight fixtures and simplify multi-drag integration tests in test_multidim_drag.py ENH: Exposes multidimensionality and validated grid extrapolation in function.py * MNT: renamed linear_grid to regular_grid for easy to understand nomenclature - MNT: added a fallback to reynolds calculation in flight.py to avoid mu is zero case - MNT: updated test_shepard_fallback_warning name and docstring to match implementation in test_function_grid.py * MNT: replaced the broad except Exception: with except (TypeError, ValueError, OverflowError): in get_drag_coefficient of flight.py * TST: added from_grid unit tests to cover constructor-level validation and the explicit API. * MNT: format and lint update to test_function_from_grid.py * DOC: changelog.md update for multidim drag * Address review feedback: add unsorted axis warning, flatten_for_compatibility parameter, and early return guard clause Co-authored-by: aZira371 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Gui-FernandesBR <[email protected]> Co-authored-by: Ishan <[email protected]> Co-authored-by: Ishan <[email protected]>
1 parent 607af52 commit 45a7369

File tree

9 files changed

+1179
-28
lines changed

9 files changed

+1179
-28
lines changed

.pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ good-names=FlightPhases,
229229
center_of_mass_without_motor_to_CDM,
230230
motor_center_of_dry_mass_to_CDM,
231231
generic_motor_cesaroni_M1520,
232+
Re, # Reynolds number
232233

233234
# Good variable names regexes, separated by a comma. If names match any regex,
234235
# they will always be accepted

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Attention: The newest changes should be on top -->
3232

3333
### Added
3434

35+
- ENH: Add multi-dimensional drag coefficient support (Cd as function of M, Re, α) [#875](https://github.com/RocketPy-Team/RocketPy/pull/875)
36+
- ENH: Add save functionality to `_MonteCarloPlots.all` method [#848](https://github.com/RocketPy-Team/RocketPy/pull/848)
3537
- ENH: add animations for motor propellant mass and tank fluid volumes [#894](https://github.com/RocketPy-Team/RocketPy/pull/894)
3638
- ENH: Rail button bending moments calculation in Flight class [#893](https://github.com/RocketPy-Team/RocketPy/pull/893)
3739
- ENH: Implement Bootstrapping for Confidence Interval Estimation [#891](https://github.com/RocketPy-Team/RocketPy/pull/897)

rocketpy/mathutils/function.py

Lines changed: 360 additions & 2 deletions
Large diffs are not rendered by default.

rocketpy/rocket/rocket.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -341,20 +341,28 @@ def __init__( # pylint: disable=too-many-statements
341341
)
342342

343343
# Define aerodynamic drag coefficients
344-
self.power_off_drag = Function(
345-
power_off_drag,
346-
"Mach Number",
347-
"Drag Coefficient with Power Off",
348-
"linear",
349-
"constant",
350-
)
351-
self.power_on_drag = Function(
352-
power_on_drag,
353-
"Mach Number",
354-
"Drag Coefficient with Power On",
355-
"linear",
356-
"constant",
357-
)
344+
# If already a Function, use it directly (preserves multi-dimensional drag)
345+
if isinstance(power_off_drag, Function):
346+
self.power_off_drag = power_off_drag
347+
else:
348+
self.power_off_drag = Function(
349+
power_off_drag,
350+
"Mach Number",
351+
"Drag Coefficient with Power Off",
352+
"linear",
353+
"constant",
354+
)
355+
356+
if isinstance(power_on_drag, Function):
357+
self.power_on_drag = power_on_drag
358+
else:
359+
self.power_on_drag = Function(
360+
power_on_drag,
361+
"Mach Number",
362+
"Drag Coefficient with Power On",
363+
"linear",
364+
"constant",
365+
)
358366

359367
# Create a, possibly, temporary empty motor
360368
# self.motors = Components() # currently unused, only 1 motor is supported

rocketpy/simulation/flight.py

Lines changed: 156 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,90 @@ def lateral_surface_wind(self):
13911391

13921392
return -wind_u * np.cos(heading_rad) + wind_v * np.sin(heading_rad)
13931393

1394+
def __get_drag_coefficient(self, drag_function, mach, z, freestream_velocity_body):
1395+
"""Calculate drag coefficient, handling both 1D and multi-dimensional functions.
1396+
1397+
Parameters
1398+
----------
1399+
drag_function : Function
1400+
The drag coefficient function (power_on_drag or power_off_drag)
1401+
mach : float
1402+
Mach number
1403+
z : float
1404+
Altitude in meters
1405+
freestream_velocity_body : Vector or array-like
1406+
Freestream velocity in body frame [stream_vx_b, stream_vy_b, stream_vz_b]
1407+
1408+
Returns
1409+
-------
1410+
float
1411+
Drag coefficient value
1412+
"""
1413+
# Early return for 1D drag functions (only mach number)
1414+
if not isinstance(drag_function, Function) or not getattr(
1415+
drag_function, "is_multidimensional", False
1416+
):
1417+
return drag_function.get_value_opt(mach)
1418+
1419+
# Multi-dimensional drag function - calculate additional parameters
1420+
1421+
# Calculate Reynolds number: Re = rho * V * L / mu
1422+
# where L is characteristic length (rocket diameter)
1423+
rho = self.env.density.get_value_opt(z)
1424+
mu = self.env.dynamic_viscosity.get_value_opt(z)
1425+
freestream_speed = np.linalg.norm(freestream_velocity_body)
1426+
characteristic_length = 2 * self.rocket.radius # Diameter
1427+
# Defensive: avoid division by zero or non-finite viscosity values.
1428+
# Use a small epsilon fallback if `mu` is zero, negative, NaN or infinite.
1429+
try:
1430+
mu_val = float(mu)
1431+
except (TypeError, ValueError, OverflowError):
1432+
# Only catch errors related to invalid numeric conversion.
1433+
# Avoid catching broad Exception to satisfy linters and
1434+
# allow other unexpected errors to surface.
1435+
mu_val = 0.0
1436+
if not np.isfinite(mu_val) or mu_val <= 0.0:
1437+
mu_safe = 1e-10
1438+
else:
1439+
mu_safe = mu_val
1440+
1441+
reynolds = rho * freestream_speed * characteristic_length / mu_safe
1442+
1443+
# Calculate angle of attack
1444+
# Angle between freestream velocity and rocket axis (z-axis in body frame)
1445+
# The z component of freestream velocity in body frame
1446+
if hasattr(freestream_velocity_body, "z"):
1447+
stream_vz_b = -freestream_velocity_body.z
1448+
else:
1449+
stream_vz_b = -freestream_velocity_body[2]
1450+
1451+
# Normalize and calculate angle
1452+
if freestream_speed > 1e-6:
1453+
cos_alpha = stream_vz_b / freestream_speed
1454+
# Clamp to [-1, 1] to avoid numerical issues
1455+
cos_alpha = np.clip(cos_alpha, -1.0, 1.0)
1456+
alpha_rad = np.arccos(cos_alpha)
1457+
alpha_deg = np.rad2deg(alpha_rad)
1458+
else:
1459+
alpha_deg = 0.0
1460+
1461+
# Determine which parameters to pass based on input names
1462+
input_names = [name.lower() for name in drag_function.__inputs__]
1463+
args = []
1464+
1465+
for name in input_names:
1466+
if "mach" in name or name == "m":
1467+
args.append(mach)
1468+
elif "reynolds" in name or name == "re":
1469+
args.append(reynolds)
1470+
elif "alpha" in name or name == "a" or "attack" in name:
1471+
args.append(alpha_deg)
1472+
else:
1473+
# Unknown parameter, default to mach
1474+
args.append(mach)
1475+
1476+
return drag_function.get_value_opt(*args)
1477+
13941478
def udot_rail1(self, t, u, post_processing=False):
13951479
"""Calculates derivative of u state vector with respect to time
13961480
when rocket is flying in 1 DOF motion in the rail.
@@ -1427,7 +1511,32 @@ def udot_rail1(self, t, u, post_processing=False):
14271511
+ (vz) ** 2
14281512
) ** 0.5
14291513
free_stream_mach = free_stream_speed / self.env.speed_of_sound.get_value_opt(z)
1430-
drag_coeff = self.rocket.power_on_drag.get_value_opt(free_stream_mach)
1514+
1515+
# For rail motion, rocket is constrained - velocity mostly along z-axis in body frame
1516+
# Calculate velocity in body frame (simplified for rail)
1517+
a11 = 1 - 2 * (e2**2 + e3**2)
1518+
a12 = 2 * (e1 * e2 - e0 * e3)
1519+
a13 = 2 * (e1 * e3 + e0 * e2)
1520+
a21 = 2 * (e1 * e2 + e0 * e3)
1521+
a22 = 1 - 2 * (e1**2 + e3**2)
1522+
a23 = 2 * (e2 * e3 - e0 * e1)
1523+
a31 = 2 * (e1 * e3 - e0 * e2)
1524+
a32 = 2 * (e2 * e3 + e0 * e1)
1525+
a33 = 1 - 2 * (e1**2 + e2**2)
1526+
1527+
# Freestream velocity in body frame
1528+
wind_vx = self.env.wind_velocity_x.get_value_opt(z)
1529+
wind_vy = self.env.wind_velocity_y.get_value_opt(z)
1530+
stream_vx_b = a11 * (wind_vx - vx) + a21 * (wind_vy - vy) + a31 * (-vz)
1531+
stream_vy_b = a12 * (wind_vx - vx) + a22 * (wind_vy - vy) + a32 * (-vz)
1532+
stream_vz_b = a13 * (wind_vx - vx) + a23 * (wind_vy - vy) + a33 * (-vz)
1533+
1534+
drag_coeff = self.__get_drag_coefficient(
1535+
self.rocket.power_on_drag,
1536+
free_stream_mach,
1537+
z,
1538+
[stream_vx_b, stream_vy_b, stream_vz_b],
1539+
)
14311540

14321541
# Calculate Forces
14331542
pressure = self.env.pressure.get_value_opt(z)
@@ -1597,12 +1706,38 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals
15971706
) ** 0.5
15981707
free_stream_mach = free_stream_speed / speed_of_sound
15991708

1709+
# Get rocket velocity in body frame (needed for drag calculation)
1710+
vx_b = a11 * vx + a21 * vy + a31 * vz
1711+
vy_b = a12 * vx + a22 * vy + a32 * vz
1712+
vz_b = a13 * vx + a23 * vy + a33 * vz
1713+
1714+
# Calculate freestream velocity in body frame
1715+
stream_vx_b = (
1716+
a11 * (wind_velocity_x - vx) + a21 * (wind_velocity_y - vy) + a31 * (-vz)
1717+
)
1718+
stream_vy_b = (
1719+
a12 * (wind_velocity_x - vx) + a22 * (wind_velocity_y - vy) + a32 * (-vz)
1720+
)
1721+
stream_vz_b = (
1722+
a13 * (wind_velocity_x - vx) + a23 * (wind_velocity_y - vy) + a33 * (-vz)
1723+
)
1724+
16001725
# Determine aerodynamics forces
16011726
# Determine Drag Force
16021727
if t < self.rocket.motor.burn_out_time:
1603-
drag_coeff = self.rocket.power_on_drag.get_value_opt(free_stream_mach)
1728+
drag_coeff = self.__get_drag_coefficient(
1729+
self.rocket.power_on_drag,
1730+
free_stream_mach,
1731+
z,
1732+
[stream_vx_b, stream_vy_b, stream_vz_b],
1733+
)
16041734
else:
1605-
drag_coeff = self.rocket.power_off_drag.get_value_opt(free_stream_mach)
1735+
drag_coeff = self.__get_drag_coefficient(
1736+
self.rocket.power_off_drag,
1737+
free_stream_mach,
1738+
z,
1739+
[stream_vx_b, stream_vy_b, stream_vz_b],
1740+
)
16061741
rho = self.env.density.get_value_opt(z)
16071742
R3 = -0.5 * rho * (free_stream_speed**2) * self.rocket.area * drag_coeff
16081743
for air_brakes in self.rocket.air_brakes:
@@ -1624,10 +1759,6 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals
16241759
# Off center moment
16251760
M1 += self.rocket.cp_eccentricity_y * R3
16261761
M2 -= self.rocket.cp_eccentricity_x * R3
1627-
# Get rocket velocity in body frame
1628-
vx_b = a11 * vx + a21 * vy + a31 * vz
1629-
vy_b = a12 * vx + a22 * vy + a32 * vz
1630-
vz_b = a13 * vx + a23 * vy + a33 * vz
16311762
# Calculate lift and moment for each component of the rocket
16321763
velocity_in_body_frame = Vector([vx_b, vy_b, vz_b])
16331764
w = Vector([omega1, omega2, omega3])
@@ -1992,17 +2123,33 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too
19922123
speed_of_sound = self.env.speed_of_sound.get_value_opt(z)
19932124
free_stream_mach = free_stream_speed / speed_of_sound
19942125

2126+
# Get rocket velocity in body frame (needed for drag calculation)
2127+
velocity_in_body_frame = Kt @ v
2128+
# Calculate freestream velocity in body frame
2129+
freestream_velocity = wind_velocity - v
2130+
freestream_velocity_body = Kt @ freestream_velocity
2131+
19952132
if self.rocket.motor.burn_start_time < t < self.rocket.motor.burn_out_time:
19962133
pressure = self.env.pressure.get_value_opt(z)
19972134
net_thrust = max(
19982135
self.rocket.motor.thrust.get_value_opt(t)
19992136
+ self.rocket.motor.pressure_thrust(pressure),
20002137
0,
20012138
)
2002-
drag_coeff = self.rocket.power_on_drag.get_value_opt(free_stream_mach)
2139+
drag_coeff = self.__get_drag_coefficient(
2140+
self.rocket.power_on_drag,
2141+
free_stream_mach,
2142+
z,
2143+
freestream_velocity_body,
2144+
)
20032145
else:
20042146
net_thrust = 0
2005-
drag_coeff = self.rocket.power_off_drag.get_value_opt(free_stream_mach)
2147+
drag_coeff = self.__get_drag_coefficient(
2148+
self.rocket.power_off_drag,
2149+
free_stream_mach,
2150+
z,
2151+
freestream_velocity_body,
2152+
)
20062153
R3 += -0.5 * rho * (free_stream_speed**2) * self.rocket.area * drag_coeff
20072154
for air_brakes in self.rocket.air_brakes:
20082155
if air_brakes.deployment_level > 0:
@@ -2020,8 +2167,6 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too
20202167
R3 = air_brakes_force # Substitutes rocket drag coefficient
20212168
else:
20222169
R3 += air_brakes_force
2023-
# Get rocket velocity in body frame
2024-
velocity_in_body_frame = Kt @ v
20252170
# Calculate lift and moment for each component of the rocket
20262171
for aero_surface, _ in self.rocket.aerodynamic_surfaces:
20272172
# Component cp relative to CDM in body frame

tests/fixtures/flight/flight_fixtures.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import numpy as np
12
import pytest
23

3-
from rocketpy import Flight
4+
from rocketpy import Flight, Function, Rocket
45

56

67
@pytest.fixture
@@ -273,3 +274,90 @@ def flight_calisto_with_sensors(calisto_with_sensors, example_plain_env):
273274
time_overshoot=False,
274275
terminate_on_apogee=True,
275276
)
277+
278+
279+
@pytest.fixture
280+
def flight_alpha(example_plain_env, cesaroni_m1670):
281+
"""Fixture that returns a Flight using an alpha-dependent 3D Cd function."""
282+
# Create grid data
283+
mach = np.array([0.0, 0.5, 1.0, 1.5])
284+
reynolds = np.array([1e5, 1e6])
285+
alpha = np.array([0.0, 5.0, 10.0, 15.0])
286+
M, _, A = np.meshgrid(mach, reynolds, alpha, indexing="ij")
287+
cd_data = 0.3 + 0.05 * M + 0.03 * A
288+
cd_data = np.clip(cd_data, 0.2, 2.0)
289+
290+
drag_func = Function.from_grid(
291+
cd_data,
292+
[mach, reynolds, alpha],
293+
inputs=["Mach", "Reynolds", "Alpha"],
294+
outputs="Cd",
295+
)
296+
297+
env = example_plain_env
298+
env.set_atmospheric_model(type="standard_atmosphere")
299+
300+
# Build rocket and flight
301+
rocket = Rocket(
302+
radius=0.0635,
303+
mass=16.24,
304+
inertia=(6.321, 6.321, 0.034),
305+
power_off_drag=drag_func,
306+
power_on_drag=drag_func,
307+
center_of_mass_without_motor=0,
308+
coordinate_system_orientation="tail_to_nose",
309+
)
310+
rocket.set_rail_buttons(0.2, -0.5, 30)
311+
rocket.add_motor(cesaroni_m1670, position=-1.255)
312+
313+
return Flight(
314+
rocket=rocket,
315+
environment=env,
316+
rail_length=5.2,
317+
inclination=85,
318+
heading=0,
319+
)
320+
321+
322+
@pytest.fixture
323+
def flight_flat(example_plain_env, cesaroni_m1670):
324+
"""Fixture that returns a Flight using an alpha-averaged (flat) Cd function."""
325+
# Create grid data
326+
mach = np.array([0.0, 0.5, 1.0, 1.5])
327+
reynolds = np.array([1e5, 1e6])
328+
alpha = np.array([0.0, 5.0, 10.0, 15.0])
329+
M, _, A = np.meshgrid(mach, reynolds, alpha, indexing="ij")
330+
cd_data = 0.3 + 0.05 * M + 0.03 * A
331+
cd_data = np.clip(cd_data, 0.2, 2.0)
332+
333+
cd_flat = cd_data.mean(axis=2)
334+
drag_flat = Function.from_grid(
335+
cd_flat,
336+
[mach, reynolds],
337+
inputs=["Mach", "Reynolds"],
338+
outputs="Cd",
339+
)
340+
341+
env = example_plain_env
342+
env.set_atmospheric_model(type="standard_atmosphere")
343+
344+
# Build rocket and flight
345+
rocket = Rocket(
346+
radius=0.0635,
347+
mass=16.24,
348+
inertia=(6.321, 6.321, 0.034),
349+
power_off_drag=drag_flat,
350+
power_on_drag=drag_flat,
351+
center_of_mass_without_motor=0,
352+
coordinate_system_orientation="tail_to_nose",
353+
)
354+
rocket.set_rail_buttons(0.2, -0.5, 30)
355+
rocket.add_motor(cesaroni_m1670, position=-1.255)
356+
357+
return Flight(
358+
rocket=rocket,
359+
environment=env,
360+
rail_length=5.2,
361+
inclination=85,
362+
heading=0,
363+
)

0 commit comments

Comments
 (0)