1+ import inspect
2+
13from rocketpy .mathutils import Function
24from rocketpy .plots .aero_surface_plots import _LinearGenericSurfacePlots
35from rocketpy .prints .aero_surface_prints import _LinearGenericSurfacePrints
6+ from rocketpy .rocket .aero_surface .aero_coefficient import AeroCoefficient
47from 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" ,
0 commit comments