Skip to content

Commit fb236c6

Browse files
committed
ENH: add force convention to LinearGenericSurface
1 parent a2c617c commit fb236c6

3 files changed

Lines changed: 283 additions & 15 deletions

File tree

rocketpy/rocket/aero_surface/generic_surface.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -242,15 +242,13 @@ def __init__(
242242
self.force_convention = self._resolve_force_convention(
243243
coefficients, force_convention
244244
)
245-
# The wind->body conversion only applies to surfaces whose coefficients
246-
# are the full body-frame forces (cN/cY/cA). The linear model uses
247-
# coefficient derivatives (cN_alpha, ...) whose frame is fixed by name.
245+
# Wind-frame force input (cL/cQ/cD) is converted once to the canonical
246+
# body-frame coefficients before validation. Each surface supplies the
247+
# conversion appropriate to its coefficients: the generic surface rotates
248+
# the full force coefficients, while the linear model recombines the
249+
# coefficient derivatives (see LinearGenericSurface._wind_input_to_body).
248250
# A non-dict input falls through to _check_coefficients, which rejects it.
249-
if (
250-
self.force_convention == "wind"
251-
and "cN" in default_coefficients
252-
and isinstance(coefficients, dict)
253-
):
251+
if self.force_convention == "wind" and isinstance(coefficients, dict):
254252
coefficients = self._wind_input_to_body(coefficients)
255253
self._check_coefficients(coefficients, default_coefficients)
256254
coefficients = self._complete_coefficients(coefficients, default_coefficients)
@@ -505,23 +503,35 @@ def _coefficient_option(option, coeff_name):
505503
_WIND_FORCE_NAMES = ("cL", "cQ", "cD")
506504
_BODY_FORCE_NAMES = ("cN", "cY", "cA")
507505

506+
def _force_frames_present(self, coefficients):
507+
"""Report which force frames the input coefficient names belong to, as
508+
``(has_wind, has_body)``.
509+
510+
A generic surface matches the plain force names (``cL``/``cQ``/``cD`` for
511+
wind, ``cN``/``cY``/``cA`` for body). The linear model overrides this to
512+
match those same names as derivative prefixes (``cL_alpha`` ...).
513+
"""
514+
keys = set(coefficients)
515+
has_wind = bool(keys & set(self._WIND_FORCE_NAMES))
516+
has_body = bool(keys & set(self._BODY_FORCE_NAMES))
517+
return has_wind, has_body
518+
508519
def _resolve_force_convention(self, coefficients, force_convention):
509520
"""Decide whether the input force coefficients are given in the wind
510521
frame (``cL``/``cQ``/``cD``) or the body frame (``cN``/``cY``/``cA``).
511522
512523
When ``force_convention`` is ``None`` the frame is inferred from the
513-
coefficient names; mixing the two frames is rejected.
524+
coefficient names; mixing the two frames is rejected. With no force
525+
coefficients to infer from, the canonical body frame is assumed.
514526
"""
515-
keys = set(coefficients)
516-
has_wind = bool(keys & set(self._WIND_FORCE_NAMES))
517-
has_body = bool(keys & set(self._BODY_FORCE_NAMES))
527+
has_wind, has_body = self._force_frames_present(coefficients)
518528
if force_convention is None:
519529
if has_wind and has_body:
520530
raise ValueError(
521531
"Mixed wind (cL/cQ/cD) and body (cN/cY/cA) force "
522532
"coefficients; pass force_convention='wind' or 'body'."
523533
)
524-
return "body" if has_body else "wind"
534+
return "wind" if has_wind else "body"
525535
if force_convention not in ("wind", "body"):
526536
raise ValueError(
527537
f"force_convention must be 'wind' or 'body', got {force_convention!r}."

rocketpy/rocket/aero_surface/linear_generic_surface.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import inspect
2+
13
from rocketpy.mathutils import Function
24
from rocketpy.plots.aero_surface_plots import _LinearGenericSurfacePlots
35
from rocketpy.prints.aero_surface_prints import _LinearGenericSurfacePrints
6+
from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient
47
from rocketpy.rocket.aero_surface.generic_surface import GenericSurface
58

69

@@ -22,6 +25,7 @@ def __init__(
2225
name="Generic Linear Surface",
2326
interpolation=None,
2427
extrapolation=None,
28+
force_convention=None,
2529
):
2630
"""Create a generic linear aerodynamic surface, defined by its
2731
aerodynamic coefficients derivatives. This surface is used to model any
@@ -35,6 +39,13 @@ def __init__(
3539
contain at least one of the following: "alpha", "beta", "mach",
3640
"reynolds", "pitch_rate", "yaw_rate" and "roll_rate".
3741
42+
By default the force-coefficient derivatives are the body-frame ones
43+
(``cN_*`` normal, ``cY_*`` side, ``cA_*`` axial; see
44+
``force_convention``). You may instead give the wind-frame derivatives
45+
``cL_*`` (lift), ``cQ_*`` (side) and ``cD_*`` (drag) -- for example
46+
``cL_alpha`` in place of ``cN_alpha``; they are converted once to the
47+
body-frame set at construction.
48+
3849
See Also
3950
--------
4051
:ref:`genericsurfaces`.
@@ -57,7 +68,10 @@ def __init__(
5768
yaw moment ``cn`` or roll moment ``cl``; the variable is ``0`` (the
5869
value at zero angle of attack, zero sideslip and zero rates),
5970
``alpha``, ``beta``, ``p`` (roll rate), ``q`` (pitch rate) or ``r``
60-
(yaw rate). The full list is:\n
71+
(yaw rate). With ``force_convention="wind"`` the force derivatives are
72+
named after the wind-frame coefficients instead (lift ``cL``, side
73+
``cQ``, drag ``cD`` -- e.g. ``cL_alpha``, ``cD_0``, ``cQ_beta``); the
74+
moment names are unchanged. The full (body-frame) list is:\n
6175
cN_0: callable, str, optional
6276
Coefficient of normal force at zero angle of attack. Default is 0.\n
6377
cN_alpha: callable, str, optional
@@ -193,6 +207,21 @@ def __init__(
193207
uses ``"constant"`` for tables built here and keeps whatever a
194208
pre-built ``Function`` already carries. Only affects tabulated
195209
sources (constants and callables are evaluated directly).
210+
force_convention : str, optional
211+
The frame your force-coefficient derivatives are given in. ``"body"``
212+
for the body-frame derivatives ``cN_*`` (normal), ``cY_*`` (side) and
213+
``cA_*`` (axial); ``"wind"`` for the aerodynamic-frame derivatives
214+
``cL_*`` (lift), ``cQ_*`` (side) and ``cD_*`` (drag). The moment
215+
derivatives (``cm_*``, ``cn_*``, ``cl_*``) are the same in both.
216+
``None`` (the default) infers the frame from the coefficient names you
217+
pass and assumes body when none are given. A wind-frame input is
218+
converted once to the body-frame derivatives the surface stores, by
219+
linearizing the angle-of-attack/sideslip rotation about zero: the
220+
straight renames ``cN_0 = cL_0``, ``cN_beta = cL_beta``, the rate
221+
derivatives, and the cross terms ``cN_alpha = cL_alpha + cD_0``,
222+
``cY_beta = cQ_beta - cD_0``, ``cA_alpha = cD_alpha - cL_0`` and
223+
``cA_beta = cD_beta + cQ_0``. At zero angle this reduces to
224+
``cN = cL``, ``cY = cQ``, ``cA = cD``.
196225
"""
197226

198227
super().__init__(
@@ -203,6 +232,7 @@ def __init__(
203232
name=name,
204233
extrapolation=extrapolation,
205234
interpolation=interpolation,
235+
force_convention=force_convention,
206236
)
207237

208238
self.compute_all_coefficients()
@@ -271,6 +301,126 @@ def _get_default_coefficients(self):
271301
}
272302
return default_coefficients
273303

304+
# Body force-coefficient prefix -> wind force-coefficient prefix, used to
305+
# name the accepted wind-frame inputs. The per-plane suffixes (_0, _alpha,
306+
# _beta, _p, _q, _r) and the moment coefficients (cm/cn/cl) are frame-shared.
307+
_BODY_TO_WIND_PREFIX = {"cN": "cL", "cY": "cQ", "cA": "cD"}
308+
309+
def _force_frames_present(self, coefficients):
310+
"""Detect the force frame from the derivative-name prefixes: a wind key
311+
looks like ``cL_alpha``/``cD_0``/``cQ_beta`` and a body key like
312+
``cN_alpha``/``cA_0``/``cY_beta``. The moment derivatives (``cm_*``,
313+
``cn_*``, ``cl_*``) are frame-shared and ignored here.
314+
"""
315+
prefixes = {key.split("_", 1)[0] for key in coefficients}
316+
has_wind = bool(prefixes & set(self._WIND_FORCE_NAMES))
317+
has_body = bool(prefixes & set(self._BODY_FORCE_NAMES))
318+
return has_wind, has_body
319+
320+
def _wind_default_coefficient_names(self):
321+
"""The valid wind-frame input names: the body defaults with the force
322+
prefixes swapped to wind (``cN_* -> cL_*``, ``cY_* -> cQ_*``,
323+
``cA_* -> cD_*``); the moment names are unchanged.
324+
"""
325+
names = set()
326+
for key in self._get_default_coefficients():
327+
prefix, sep, suffix = key.partition("_")
328+
wind_prefix = self._BODY_TO_WIND_PREFIX.get(prefix, prefix)
329+
names.add(f"{wind_prefix}{sep}{suffix}")
330+
return names
331+
332+
def _wind_input_to_body(self, coefficients):
333+
"""Convert wind-frame coefficient derivatives (``cL_*``/``cD_*``/``cQ_*``)
334+
into the canonical body-frame derivatives (``cN_*``/``cY_*``/``cA_*``).
335+
336+
The full body-frame force coefficients are the wind ones rotated by the
337+
angle of attack and sideslip; linearizing that rotation about
338+
``alpha = beta = 0`` gives, to first order, a coefficient-derivative map
339+
with four cross-frame terms::
340+
341+
cN_alpha = cL_alpha + cD_0 cA_alpha = cD_alpha - cL_0
342+
cY_beta = cQ_beta - cD_0 cA_beta = cD_beta + cQ_0
343+
344+
Every other derivative is a straight rename (``cN_0 = cL_0``,
345+
``cN_beta = cL_beta``, the rate derivatives ``cN_p = cL_p`` ..., and the
346+
wind side/axial analogues). At zero angle this reduces to ``cN = cL``,
347+
``cY = cQ``, ``cA = cD``, matching the generic surface. The moment
348+
derivatives (``cm_*``/``cn_*``/``cl_*``) are frame-shared and pass
349+
through unchanged.
350+
"""
351+
invalid = set(coefficients) - self._wind_default_coefficient_names()
352+
if invalid:
353+
raise ValueError(
354+
f"Invalid coefficient name(s) used in key(s): {', '.join(invalid)}. "
355+
"Check the documentation for valid names."
356+
)
357+
358+
def wind(name):
359+
return coefficients.get(name, 0)
360+
361+
body = {
362+
"cN_0": wind("cL_0"),
363+
"cN_alpha": self._combine(wind("cL_alpha"), wind("cD_0"), 1.0, "cN_alpha"),
364+
"cN_beta": wind("cL_beta"),
365+
"cN_p": wind("cL_p"),
366+
"cN_q": wind("cL_q"),
367+
"cN_r": wind("cL_r"),
368+
"cY_0": wind("cQ_0"),
369+
"cY_alpha": wind("cQ_alpha"),
370+
"cY_beta": self._combine(wind("cQ_beta"), wind("cD_0"), -1.0, "cY_beta"),
371+
"cY_p": wind("cQ_p"),
372+
"cY_q": wind("cQ_q"),
373+
"cY_r": wind("cQ_r"),
374+
"cA_0": wind("cD_0"),
375+
"cA_alpha": self._combine(wind("cD_alpha"), wind("cL_0"), -1.0, "cA_alpha"),
376+
"cA_beta": self._combine(wind("cD_beta"), wind("cQ_0"), 1.0, "cA_beta"),
377+
"cA_p": wind("cD_p"),
378+
"cA_q": wind("cD_q"),
379+
"cA_r": wind("cD_r"),
380+
}
381+
# Moment derivatives are the same in both frames; pass them through.
382+
for name, value in coefficients.items():
383+
if name.split("_", 1)[0] not in self._WIND_FORCE_NAMES:
384+
body[name] = value
385+
return body
386+
387+
def _as_coefficient(self, source, name):
388+
"""Wrap a raw coefficient input as an :class:`AeroCoefficient` over this
389+
surface's variables (used when recombining wind-frame derivatives).
390+
"""
391+
return AeroCoefficient(
392+
source,
393+
unsteady_aero=self._unsteady_aero,
394+
control_variables=self.control_variables,
395+
name=name,
396+
)
397+
398+
def _combine(self, first, second, sign, name):
399+
"""Return a coefficient equal to ``first + sign * second``.
400+
401+
When one term is identically zero the other is returned directly (as a
402+
renamed coefficient), so a derivative that is really just a rename keeps
403+
its original, low-dimensional form. Otherwise the two are summed by a
404+
small wrapper evaluated over the full variable tuple.
405+
"""
406+
coeff_first = self._as_coefficient(first, name)
407+
coeff_second = self._as_coefficient(second, name)
408+
if coeff_second.is_zero:
409+
return coeff_first
410+
if coeff_first.is_zero:
411+
return coeff_second if sign > 0 else coeff_second * -1.0
412+
first_opt = coeff_first.get_value_opt
413+
second_opt = coeff_second.get_value_opt
414+
415+
def combined(*args):
416+
return first_opt(*args) + sign * second_opt(*args)
417+
418+
combined.__signature__ = inspect.Signature(
419+
inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD)
420+
for var in self.independent_vars
421+
)
422+
return self._as_coefficient(combined, name)
423+
274424
_COEFFICIENT_INPUTS = [
275425
"alpha",
276426
"beta",

tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import pytest
22

3-
from rocketpy import Function, LinearGenericSurface
3+
from rocketpy import Function, GenericSurface, LinearGenericSurface
44
from rocketpy.mathutils import Vector
55

66
REFERENCE_AREA = 1
77
REFERENCE_LENGTH = 1
88

9+
# (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)
10+
_ARGS = (0.0, 0.0, 0.5, 1e6, 0.0, 0.0, 0.0)
11+
912

1013
@pytest.mark.parametrize(
1114
"coefficients",
@@ -121,3 +124,108 @@ def test_roll_damping_uses_reduced_rate():
121124
# Old (raw-rate) formulation -- identical value:
122125
old_damping_scaling = 0.5 * rho * speed * ref_area * ref_length**2 / 2
123126
assert roll_moment == pytest.approx(old_damping_scaling * cl_p * raw_roll)
127+
128+
129+
def test_force_convention_inference():
130+
"""The input frame is inferred from the derivative names when
131+
``force_convention`` is not given, defaulting to body when there are no
132+
force derivatives to infer from."""
133+
assert LinearGenericSurface(1, 1, {"cN_alpha": 2.0}).force_convention == "body"
134+
assert LinearGenericSurface(1, 1, {"cL_alpha": 2.0}).force_convention == "wind"
135+
# Moment-only / empty input carries no force frame -> body (canonical).
136+
assert LinearGenericSurface(1, 1, {"cm_alpha": 1.0}).force_convention == "body"
137+
assert LinearGenericSurface(1, 1, {}).force_convention == "body"
138+
139+
140+
def test_mixed_frame_input_raises():
141+
"""Supplying both wind (cL_*) and body (cN_*) force derivatives without
142+
declaring the frame is rejected."""
143+
with pytest.raises(ValueError, match="[Mm]ixed"):
144+
LinearGenericSurface(1, 1, {"cL_alpha": 1.0, "cN_0": 1.0})
145+
146+
147+
def test_invalid_wind_coefficient_name_raises():
148+
"""A wind-frame input with an unknown derivative name is rejected."""
149+
with pytest.raises(ValueError, match="Invalid coefficient name"):
150+
LinearGenericSurface(1, 1, {"cL_gamma": 1.0}, force_convention="wind")
151+
152+
153+
def test_wind_derivatives_convert_to_body_first_order():
154+
"""Wind-frame derivatives are converted to the body-frame set by linearizing
155+
the wind/body rotation about zero: the four cross terms plus straight
156+
renames. A nonzero base drag ``cD_0`` couples into the normal- and
157+
axial-force slopes."""
158+
surface = LinearGenericSurface(
159+
reference_area=0.01,
160+
reference_length=0.1,
161+
coefficients={
162+
"cL_0": 0.1,
163+
"cL_alpha": 5.0,
164+
"cD_0": 0.3,
165+
"cD_alpha": 0.2,
166+
"cQ_beta": -4.0,
167+
"cQ_0": 0.05,
168+
"cm_alpha": -2.0, # frame-shared moment, must pass through unchanged
169+
},
170+
force_convention="wind",
171+
)
172+
assert surface.force_convention == "wind"
173+
assert surface.cN_0.get_value_opt(*_ARGS) == pytest.approx(0.1) # cL_0
174+
assert surface.cN_alpha.get_value_opt(*_ARGS) == pytest.approx(5.3) # cL_alpha+cD_0
175+
assert surface.cY_beta.get_value_opt(*_ARGS) == pytest.approx(-4.3) # cQ_beta-cD_0
176+
assert surface.cA_0.get_value_opt(*_ARGS) == pytest.approx(0.3) # cD_0
177+
assert surface.cA_alpha.get_value_opt(*_ARGS) == pytest.approx(0.1) # cD_alpha-cL_0
178+
assert surface.cA_beta.get_value_opt(*_ARGS) == pytest.approx(0.05) # cD_beta+cQ_0
179+
assert surface.cm_alpha.get_value_opt(*_ARGS) == pytest.approx(-2.0) # passthrough
180+
181+
182+
def test_wind_input_matches_body_input():
183+
"""A wind-frame surface equals the body-frame surface built from the
184+
hand-converted derivatives, at an arbitrary angle."""
185+
wind = LinearGenericSurface(
186+
1,
187+
1,
188+
coefficients={"cL_0": 0.1, "cL_alpha": 5.0, "cD_0": 0.3, "cQ_beta": -4.0},
189+
force_convention="wind",
190+
)
191+
body = LinearGenericSurface(
192+
1,
193+
1,
194+
coefficients={
195+
"cN_0": 0.1,
196+
"cN_alpha": 5.3,
197+
"cA_0": 0.3,
198+
"cA_alpha": -0.1, # cD_alpha - cL_0
199+
"cY_beta": -4.3,
200+
},
201+
)
202+
args = (0.02, 0.01, 0.5, 1e6, 0.0, 0.0, 0.0)
203+
for coeff in ("cN", "cY", "cA"):
204+
assert getattr(wind, coeff).get_value_opt(*args) == pytest.approx(
205+
getattr(body, coeff).get_value_opt(*args)
206+
)
207+
208+
209+
def test_wind_linear_matches_generic_surface_to_first_order():
210+
"""The body-frame forces of a wind-input linear surface agree with the
211+
exact rotation used by GenericSurface to first order in the flow angles."""
212+
coeffs = {"cL_0": 0.1, "cL_alpha": 5.0, "cD_0": 0.3, "cQ_beta": -4.0}
213+
linear = LinearGenericSurface(0.01, 0.1, coeffs, force_convention="wind")
214+
generic = GenericSurface(
215+
0.01,
216+
0.1,
217+
coefficients={
218+
"cL": lambda a, b, m, re, p, q, r: 0.1 + 5.0 * a,
219+
"cD": lambda a, b, m, re, p, q, r: 0.3,
220+
"cQ": lambda a, b, m, re, p, q, r: -4.0 * b,
221+
},
222+
force_convention="wind",
223+
)
224+
eps = 1e-3
225+
for alpha, beta in [(eps, 0.0), (0.0, eps), (eps, eps)]:
226+
args = (alpha, beta, 0.5, 1e6, 0.0, 0.0, 0.0)
227+
for coeff in ("cN", "cY", "cA"):
228+
# Difference is second order in the angle (~1e-6 at eps=1e-3).
229+
assert getattr(linear, coeff).get_value_opt(*args) == pytest.approx(
230+
getattr(generic, coeff).get_value_opt(*args), abs=1e-5
231+
)

0 commit comments

Comments
 (0)