Skip to content

Commit 63cad27

Browse files
MateusStanoclaude
andcommitted
ENH: rework aerodynamic coefficients onto a body-frame GenericSurface
Every aerodynamic surface is now rooted in GenericSurface and stores its force coefficients in the body frame (cN/cY/cA) plus the cm/cn/cl moments, exposing all nine coefficients (cL/cD/cQ/cN/cY/cA/cm/cn/cl) with the wind trio lazily derived. A force_convention argument lets users supply wind- or body-frame coefficients. Barrowman surfaces (nose, tail, fin sets) keep the classic geometric normal-force/moment method, report the force at the geometric center of pressure via the classic 180-degree surface rotation, and expose cN_alpha/cY_beta stability slopes (the old clalpha relabelled). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b0c5233 commit 63cad27

30 files changed

Lines changed: 1773 additions & 824 deletions

rocketpy/mathutils/function.py

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,32 @@
4040
"regular_grid": 6,
4141
}
4242
EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2}
43+
# Maps a requested interpolation name onto a scipy ``RegularGridInterpolator``
44+
# ``method`` for gridded (N-D Cartesian) data. The 1-D-only names ``spline`` and
45+
# ``akima`` fall back to their closest grid analogs (``cubic`` and the
46+
# shape-preserving ``pchip``); anything unrecognized defaults to ``linear``.
47+
REGULAR_GRID_METHODS = {
48+
"linear": "linear",
49+
"nearest": "nearest",
50+
"slinear": "slinear",
51+
"cubic": "cubic",
52+
"quintic": "quintic",
53+
"pchip": "pchip",
54+
"spline": "cubic",
55+
"akima": "pchip",
56+
"polynomial": "cubic",
57+
}
58+
# Minimum points per axis required by each ``RegularGridInterpolator`` method.
59+
# A grid with fewer samples on any axis cannot use the higher-order methods, so
60+
# the caller falls back to linear rather than letting SciPy raise mid-build.
61+
REGULAR_GRID_MIN_POINTS = {
62+
"nearest": 1,
63+
"linear": 2,
64+
"slinear": 2,
65+
"pchip": 2,
66+
"cubic": 4,
67+
"quintic": 6,
68+
}
4369

4470

4571
class SourceType(Enum):
@@ -157,7 +183,12 @@ def __init__(
157183

158184
@classmethod
159185
def from_regular_grid_csv(
160-
cls, csv_source, variable_names, coeff_name, extrapolation
186+
cls,
187+
csv_source,
188+
variable_names,
189+
coeff_name,
190+
extrapolation,
191+
interpolation="linear",
161192
):
162193
"""Create a regular-grid Function from CSV samples when possible.
163194
@@ -171,6 +202,14 @@ def from_regular_grid_csv(
171202
Name of the output coefficient.
172203
extrapolation : str
173204
Extrapolation method passed to the Function constructor.
205+
interpolation : str, optional
206+
Requested interpolation. Mapped onto a
207+
:class:`scipy.interpolate.RegularGridInterpolator` ``method`` via
208+
:data:`REGULAR_GRID_METHODS` (e.g. ``"spline"`` -> ``"cubic"``,
209+
``"akima"`` -> ``"pchip"``); unrecognized names fall back to
210+
``"linear"``. Smooth methods require enough points per axis
211+
(``"cubic"`` needs at least 4), otherwise SciPy raises. Default
212+
``"linear"``.
174213
175214
Returns
176215
-------
@@ -215,13 +254,33 @@ def from_regular_grid_csv(
215254
return None
216255

217256
grid_data = sorted_values.reshape(tuple(axis.size for axis in axes))
218-
return cls(
257+
grid_function = cls(
219258
(axes, grid_data),
220259
inputs=variable_names,
221260
outputs=[coeff_name],
222261
interpolation="regular_grid",
223262
extrapolation=extrapolation,
224263
)
264+
# Honor the requested interpolation on the grid by rebuilding the
265+
# interpolator/extrapolator with the mapped scipy ``method``. The
266+
# constructor above always builds the default ("linear"); only rebuild
267+
# when a different method was asked for.
268+
grid_method = REGULAR_GRID_METHODS.get(interpolation, "linear")
269+
smallest_axis = min(axis.size for axis in axes)
270+
if smallest_axis < REGULAR_GRID_MIN_POINTS.get(grid_method, 2):
271+
warnings.warn(
272+
f"Grid interpolation method '{grid_method}' needs at least "
273+
f"{REGULAR_GRID_MIN_POINTS[grid_method]} points per axis, but the "
274+
f"coarsest axis of '{coeff_name}' has {smallest_axis}; falling "
275+
"back to 'linear'.",
276+
UserWarning,
277+
)
278+
grid_method = "linear"
279+
if grid_method != "linear":
280+
grid_function._grid_method = grid_method
281+
grid_function.set_interpolation("regular_grid")
282+
grid_function.set_extrapolation(grid_function.get_extrapolation_method())
283+
return grid_function
225284

226285
# Define all set methods
227286
def set_inputs(self, inputs):
@@ -318,6 +377,10 @@ def set_source(self, source): # pylint: disable=too-many-statements
318377
self.__dom_dim__ = source.shape[1] - 1
319378
self._domain = source[:, :-1]
320379
self._image = source[:, -1]
380+
# Cache per-dimension domain bounds so the N-D hot evaluation path
381+
# (``__get_value_opt_nd``) does not recompute them on every call.
382+
self._domain_min = self._domain.min(axis=0)
383+
self._domain_max = self._domain.max(axis=0)
321384

322385
# set x and y. If Function is 2D, also set z
323386
if self.__dom_dim__ == 1:
@@ -488,11 +551,20 @@ def __process_grid_source(self, source):
488551
f"{grid_data.shape[i]} points."
489552
)
490553
if not np.all(np.diff(ax) > 0):
491-
warnings.warn(
492-
f"Axis {i} is not strictly sorted in ascending order. "
493-
"RegularGridInterpolator requires sorted axes.",
494-
UserWarning,
495-
)
554+
# RegularGridInterpolator requires strictly ascending axes. Sort
555+
# this axis (and reorder the grid data along it) so descending or
556+
# shuffled inputs are accepted; repeated coordinates cannot form
557+
# a regular grid and are rejected with a clear error rather than
558+
# a cryptic SciPy failure.
559+
order = np.argsort(ax, kind="stable")
560+
ax = ax[order]
561+
grid_data = np.take(grid_data, order, axis=i)
562+
axes[i] = ax
563+
if not np.all(np.diff(ax) > 0):
564+
raise ValueError(
565+
f"Axis {i} has repeated coordinates; a regular grid "
566+
"requires strictly increasing values along each axis."
567+
)
496568

497569
self._grid_axes = axes
498570
self._grid_data = grid_data
@@ -596,7 +668,7 @@ def rbf_interpolation(x, x_min, x_max, x_data, y_data, coeffs): # pylint: disab
596668
grid_interpolator = RegularGridInterpolator(
597669
self._grid_axes,
598670
self._grid_data,
599-
method="linear",
671+
method=getattr(self, "_grid_method", "linear"),
600672
bounds_error=True,
601673
)
602674
# Store so extrapolation funcs can reuse it
@@ -720,9 +792,9 @@ def natural_extrapolation( # pylint: disable=function-redefined
720792
grid_extrapolator = RegularGridInterpolator(
721793
self._grid_axes,
722794
self._grid_data,
723-
method="linear",
795+
method=getattr(self, "_grid_method", "linear"),
724796
bounds_error=False,
725-
fill_value=None, # linear extrapolation beyond edges
797+
fill_value=None, # extrapolation beyond edges
726798
)
727799

728800
def natural_extrapolation( # pylint: disable=function-redefined
@@ -824,8 +896,15 @@ def __get_value_opt_nd(self, *args):
824896
arg_qty = len(args)
825897
result = np.empty(arg_qty)
826898

827-
min_domain = self._domain.T.min(axis=1)
828-
max_domain = self._domain.T.max(axis=1)
899+
# Domain bounds are fixed once the source is set, so they are cached in
900+
# ``set_source`` (this hot path runs per integration step); fall back to
901+
# computing them for any Function built without going through it.
902+
min_domain = getattr(self, "_domain_min", None)
903+
if min_domain is None:
904+
min_domain = self._domain.min(axis=0)
905+
max_domain = self._domain.max(axis=0)
906+
else:
907+
max_domain = self._domain_max
829908

830909
lower, upper = args < min_domain, args > max_domain
831910
extrap = np.logical_or(lower.any(axis=1), upper.any(axis=1))
@@ -4162,7 +4241,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument
41624241
else:
41634242
source = source.__name__
41644243

4165-
return {
4244+
function_dict = {
41664245
"source": source,
41674246
"title": self.title,
41684247
"inputs": self.__inputs__,
@@ -4171,6 +4250,20 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument
41714250
"extrapolation": self.__extrapolation__,
41724251
}
41734252

4253+
# A regular-grid Function cannot be rebuilt from its flat scatter
4254+
# ``source``; persist the ``(axes, grid_data)`` structure (and the mapped
4255+
# scipy method) instead, so it round-trips through ``from_dict``.
4256+
if self.__interpolation__ == "regular_grid":
4257+
function_dict["source"] = [
4258+
[np.asarray(axis).tolist() for axis in self._grid_axes],
4259+
np.asarray(self._grid_data).tolist(),
4260+
]
4261+
grid_method = getattr(self, "_grid_method", "linear")
4262+
if grid_method != "linear":
4263+
function_dict["grid_method"] = grid_method
4264+
4265+
return function_dict
4266+
41744267
@classmethod
41754268
def from_dict(cls, func_dict):
41764269
"""Creates a Function instance from a dictionary.
@@ -4184,7 +4277,7 @@ def from_dict(cls, func_dict):
41844277
if func_dict["interpolation"] is None and func_dict["extrapolation"] is None:
41854278
source = from_hex_decode(source)
41864279

4187-
return cls(
4280+
function = cls(
41884281
source=source,
41894282
interpolation=func_dict["interpolation"],
41904283
extrapolation=func_dict["extrapolation"],
@@ -4193,6 +4286,16 @@ def from_dict(cls, func_dict):
41934286
title=func_dict["title"],
41944287
)
41954288

4289+
# Restore a non-default regular-grid method (the constructor above builds
4290+
# the "linear" default); rebuild the interpolator/extrapolator with it.
4291+
grid_method = func_dict.get("grid_method")
4292+
if grid_method and grid_method != "linear":
4293+
function._grid_method = grid_method
4294+
function.set_interpolation("regular_grid")
4295+
function.set_extrapolation(function.get_extrapolation_method())
4296+
4297+
return function
4298+
41964299
@staticmethod
41974300
def __make_arith_lambda(
41984301
operator, func, other, func_dim, other_dim=0, reverse=False

rocketpy/plots/aero_surface_plots.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,14 @@ class _BarrowmanSurfacePlots(_LinearGenericSurfacePlots):
114114
geometry drawing and the lift-coefficient surface plot."""
115115

116116
def lift(self):
117-
"""Plots the lift coefficient of the aero surface as a function of Mach
118-
and the angle of attack. A 3D plot is expected. See the rocketpy.Function
119-
class for more information on how this plot is made.
117+
"""Plots the lift-curve slope (``clalpha``) of the aero surface as a
118+
function of Mach number.
120119
121120
Returns
122121
-------
123122
None
124123
"""
125-
self.aero_surface.cl()
124+
self.aero_surface.clalpha()
126125

127126
def all(self):
128127
"""Plots the surface geometry, the lift coefficient and the
@@ -274,8 +273,7 @@ class for more information on how this plot is made. Also, this method
274273
-------
275274
None
276275
"""
277-
print("Lift coefficient:")
278-
self.aero_surface.cl(filename=filename)
276+
print("Lift coefficient derivative:")
279277
self.aero_surface.clalpha_single_fin(filename=filename)
280278
self.aero_surface.clalpha_multiple_fins(filename=filename)
281279

@@ -356,8 +354,7 @@ class for more information on how this plot is made. Also, this method
356354
-------
357355
None
358356
"""
359-
print("Lift coefficient:")
360-
self.aero_surface.cl(filename=filename)
357+
print("Lift coefficient derivative:")
361358
self.aero_surface.clalpha_single_fin(filename=filename)
362359

363360
def all(self, *, filename=None):

rocketpy/plots/rocket_plots.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,10 +794,10 @@ def _center_of_pressure_range(self, plane, max_angle=np.deg2rad(15), samples=31)
794794
for angle in angles:
795795
if plane == "yz":
796796
coeffs = rocket.aerodynamic_coefficients_full(0.0, angle, 0.0)
797-
force, moment = coeffs["cQ"], coeffs["cn"]
797+
force, moment = coeffs["cY"], coeffs["cn"]
798798
else:
799799
coeffs = rocket.aerodynamic_coefficients_full(angle, 0.0, 0.0)
800-
force, moment = coeffs["cL"], coeffs["cm"]
800+
force, moment = coeffs["cN"], coeffs["cm"]
801801
if force == 0:
802802
continue
803803
position = cdm + csys * diameter * moment / force

0 commit comments

Comments
 (0)