From 32fe61839bfc0b74d68f8e2803f4693edc36d22c Mon Sep 17 00:00:00 2001 From: MateusStano Date: Mon, 22 Jun 2026 21:54:06 -0300 Subject: [PATCH 1/8] ENH: first aero refactor draft --- .../center_of_pressure_and_stability.rst | 398 ++++++++++ .../aerodynamics/elliptical_fins.rst | 11 - .../aerodynamics/individual_fins.rst | 8 - .../technical/aerodynamics/roll_equations.rst | 30 - docs/technical/index.rst | 1 + rocketpy/__init__.py | 1 + rocketpy/plots/aero_surface_plots.py | 86 +++ rocketpy/plots/flight_plots.py | 110 ++- rocketpy/plots/rocket_plots.py | 261 ++++++- rocketpy/prints/aero_surface_prints.py | 54 +- rocketpy/prints/flight_prints.py | 21 + rocketpy/prints/rocket_prints.py | 25 +- rocketpy/rocket/__init__.py | 1 + rocketpy/rocket/aero_surface/__init__.py | 3 + .../rocket/aero_surface/_barrowman_surface.py | 120 +++ .../rocket/aero_surface/aero_coefficient.py | 229 ++++++ rocketpy/rocket/aero_surface/aero_surface.py | 187 +---- rocketpy/rocket/aero_surface/air_brakes.py | 107 ++- .../controllable_generic_surface.py | 162 ++++ .../rocket/aero_surface/fins/_base_fin.py | 38 +- rocketpy/rocket/aero_surface/fins/fin.py | 50 ++ rocketpy/rocket/aero_surface/fins/fins.py | 69 +- .../aero_surface/fins/trapezoidal_fin.py | 1 - .../rocket/aero_surface/generic_surface.py | 380 ++++++--- .../aero_surface/linear_generic_surface.py | 270 ++++--- rocketpy/rocket/aero_surface/nose_cone.py | 18 +- rocketpy/rocket/aero_surface/rail_buttons.py | 64 +- rocketpy/rocket/aero_surface/tail.py | 18 +- rocketpy/rocket/rocket.py | 718 +++++++++++++++++- rocketpy/simulation/flight.py | 285 ++++++- .../simulation/helpers/flight_derivatives.py | 168 ++-- 31 files changed, 3142 insertions(+), 752 deletions(-) create mode 100644 docs/technical/aerodynamics/center_of_pressure_and_stability.rst create mode 100644 rocketpy/rocket/aero_surface/_barrowman_surface.py create mode 100644 rocketpy/rocket/aero_surface/aero_coefficient.py create mode 100644 rocketpy/rocket/aero_surface/controllable_generic_surface.py diff --git a/docs/technical/aerodynamics/center_of_pressure_and_stability.rst b/docs/technical/aerodynamics/center_of_pressure_and_stability.rst new file mode 100644 index 000000000..42f281cab --- /dev/null +++ b/docs/technical/aerodynamics/center_of_pressure_and_stability.rst @@ -0,0 +1,398 @@ +.. _aero_cp_stability: + +========================================================== +Aerodynamics: Coefficients, Centers and Stability +========================================================== + +:Author: RocketPy Team +:Date: June 2026 + +Introduction +============ + +This document describes how RocketPy models aerodynamic forces and moments and +how the rocket's stability quantities — the **aerodynamic center**, the +**center of pressure**, the **static** and **stability margins**, and the +**dynamic-stability** parameters — are derived from them. + +The model is built as a strict set of layers, each derived only from the one +below it: + +#. **Surface coefficients** — every aerodynamic surface exposes the six + dimensionless aerodynamic coefficients. +#. **Rocket aggregate** — the surfaces are summed into the rocket's total force + and moment. +#. **Stability references** — the *aerodynamic center* (linear) and the + *center of pressure* (nonlinear). +#. **Margins** — the linear (aerodynamic-center) and realized (center-of- + pressure) stability margins. +#. **Dynamic stability** — the linearized attitude oscillator. + +Since the aerodynamic-surface refactor, :class:`rocketpy.GenericSurface` is the +**root of the aerodynamic-surface hierarchy**: nose cones, fin sets, individual +fins, tails/transitions and air brakes are all described by the same coefficient +set and computed through a single coefficient-based force-and-moment model. The +geometric (Barrowman) surfaces translate their geometry into those same +coefficients (see :ref:`barrowman_mapping`), so the rocket knows the full +aerodynamic coefficient set for every surface. + +.. note:: + The legacy ``AeroSurface`` base class is deprecated. It is retained only as a + compatibility shim: :class:`rocketpy.GenericSurface` is registered as a + virtual subclass, so ``isinstance(surface, AeroSurface)`` still returns + ``True``. + +Layer 0 — The aerodynamic coefficient model +============================================ + +A generic aerodynamic surface is defined by six dimensionless coefficients, +each a function of a set of independent variables: + +- force coefficients: lift :math:`C_L`, side force :math:`C_Q`, drag :math:`C_D`; +- moment coefficients: pitch :math:`C_m`, yaw :math:`C_n`, roll :math:`C_l`. + +The standard independent variables are the angle of attack :math:`\alpha`, the +sideslip angle :math:`\beta`, the Mach number :math:`M`, the Reynolds number +:math:`Re`, and the body angular rates (pitch :math:`q`, yaw :math:`r`, roll +:math:`p`): + +.. math:: + + C_i = C_i(\alpha,\ \beta,\ M,\ Re,\ q,\ r,\ p) + +Subclasses may append extra axes — control deflections for +:class:`rocketpy.ControllableGenericSurface`, or the unsteady terms +:math:`\dot\alpha,\ \dot\beta` when ``unsteady_aero=True``. + +Forces and moments of a surface +------------------------------- + +At each step the surface receives the freestream velocity in the body frame. +Reversing it into the standard aerodynamic frame, the incidence angles are + +.. math:: + + \alpha = \operatorname{atan2}(-v_y,\ -v_z), \qquad + \beta = \operatorname{atan2}(-v_x,\ -v_z) + +With the dynamic pressure times reference area +:math:`\bar q A = \tfrac{1}{2}\rho V^2 A_\text{ref}`, the aerodynamic force +:math:`(Q, -L, -D)` is rotated from the aerodynamic frame into the body frame, +giving :math:`\mathbf{R}=(R_1, R_2, R_3)`, and the moment about the rocket's +center of dry mass is + +.. math:: + :label: moment_transport + + \mathbf{M} = \bar q A L_\text{ref}\,(C_m, C_n, C_l) + + \mathbf{r}_\text{cp} \times \mathbf{R} + +The first term is the couple carried by the moment coefficients; the second +transports the resultant force from its application point +:math:`\mathbf{r}_\text{cp}` to the center of dry mass. This is implemented in +:meth:`rocketpy.GenericSurface.compute_forces_and_moments`. + +Layer 1 — Rocket aggregate +========================== + +The simulation, and every stability quantity below, sums the surfaces into the +rocket's total body-frame force and moment about the center of dry mass. The +nonlinear aggregate at a given state is +:meth:`rocketpy.Rocket._aerodynamic_forces_and_moments`; the dimensionless +totals are exposed by :meth:`rocketpy.Rocket.aerodynamic_coefficients` (total +normal-force coefficient :math:`C_N` and pitch-moment coefficient :math:`C_m`). +The **linear** aggregate — the normal-force-curve slope and the +slope-weighted positions — is built by +:meth:`rocketpy.Rocket.evaluate_center_of_pressure` (see Layer 2). + +Layer 2 — Aerodynamic center vs. center of pressure +=================================================== + +These two are the heart of the model and are frequently confused. They are the +same physics in two regimes. + +Aerodynamic center (linear) +--------------------------- + +The **aerodynamic center** (AC) is the *linearized*, small-incidence +(:math:`\alpha=\beta=0`) location about which the pitching moment is independent +of angle of attack: + +.. math:: + :label: ac + + x_\text{AC}(M) = x_\text{ref} + - \frac{\partial C_m/\partial\alpha}{\partial C_N/\partial\alpha}\,L_\text{ref} + +It is well-conditioned, a function of Mach alone, and is the classical reference +that the static margin is built on. At the rocket level it is the +normal-force-slope-weighted average of the component locations, + +.. math:: + :label: rocket_ac + + x_\text{AC,rocket}(M) = + \frac{\sum_i k_i\, C_{N,\alpha,i}(M)\,\big(p_i - c\, z_{\text{cp},i}(M)\big)} + {\sum_i k_i\, C_{N,\alpha,i}(M)} + +with the area-correction factor :math:`k_i = A_{\text{ref},i}/A_\text{rocket}`, +:math:`p_i` the surface position and :math:`c=\pm 1` the coordinate-system +orientation. Because the weight is the normal-force slope, a zero-lift surface +(e.g. a pure-drag element) drops out cleanly. This is computed by +:meth:`rocketpy.Rocket.evaluate_center_of_pressure` and stored as +``Rocket.aerodynamic_center``. + +.. note:: + ``Rocket.cp_position`` is a **deprecated alias** for + ``Rocket.aerodynamic_center``. The historical "center of pressure" attribute + was always the aerodynamic center; the alias is kept (with a + ``DeprecationWarning``) for backward compatibility. + +Center of pressure (nonlinear) +------------------------------ + +The **center of pressure** (CP) is the point at which the *actual* resultant +aerodynamic force acts with no residual moment, at a finite angle of +attack/sideslip: + +.. math:: + :label: cp + + x_\text{CP}(\alpha,\beta,M,Re) = + x_\text{cdm} + c\,\frac{M_2 R_1 - M_1 R_2}{R_1^2 + R_2^2} + +evaluated from the Layer-1 aggregate (:math:`M = r\times F`). Unlike the AC, the +CP **moves with incidence**. It is a :math:`0/0` limit at zero incidence and +converges to the AC as :math:`\alpha,\beta \to 0`. This is +:meth:`rocketpy.Rocket.center_of_pressure`. + +To stay well-conditioned, ``center_of_pressure`` returns the aerodynamic-center +limit below ~1° of total incidence — blended between the pitch and yaw planes by +the direction of incidence (:meth:`rocketpy.Rocket._aerodynamic_center_limit`) — +so it is continuous and never spikes as the rocket oscillates through zero +incidence. The design-time travel is exposed by +``center_of_pressure_over_alpha`` and ``center_of_pressure_over_beta``. + +Pitch and yaw planes +-------------------- + +Because :class:`rocketpy.GenericSurface` allows **non-axisymmetric** rockets, the +*linear* AC is computed independently for the two planes: + +- pitch (``aerodynamic_center``) from :math:`\partial C_L/\partial\alpha` and + :math:`C_m`; +- yaw (``aerodynamic_center_yaw``) from the side-force slope and :math:`C_n`. + +They coincide for an axisymmetric rocket; ``Rocket.is_axisymmetric`` reports +whether they agree (to caliber tolerance) and +:meth:`rocketpy.Rocket.evaluate_center_of_pressure` warns when they do not, since +the scalar ``static_margin``/``stability_margin`` then describe the pitch plane +only. The **nonlinear** CP needs no such split — evaluated at the actual combined +incidence, a single axial location already captures both planes. + +Layer 3 — Static and stability margins +====================================== + +A margin is the longitudinal center-of-mass-to-stability-reference distance in +calibers (rocket diameters). With the center of mass :math:`z_\text{cm}(t)`, the +rocket radius :math:`R` and the orientation factor :math:`c`, there are **two +co-equal families**: + +**Linear (aerodynamic-center) margins.** Built on the AC; well-conditioned and +never spiking. The conventional design parameters: + +.. math:: + :label: static_margin + + \text{static margin}(t) = c\,\frac{z_\text{cm}(t) - x_\text{AC}(0)}{2R}, + \qquad + \text{stability margin}(M, t) = c\,\frac{z_\text{cm}(t) - x_\text{AC}(M)}{2R} + +The static margin (:meth:`rocketpy.Rocket.evaluate_static_margin`) is the +incompressible (:math:`M=0`) limit, a function of time; the stability margin +(:meth:`rocketpy.Rocket.evaluate_stability_margin`) is a function of Mach and +time. The ``*_yaw`` counterparts use ``aerodynamic_center_yaw``. + +**Realized (center-of-pressure) margin.** Built on the nonlinear CP at the +actual flight incidence, it reflects how the stability reference travels with +:math:`\alpha,\beta` (and combines the planes for a non-axisymmetric rocket). + +At the :class:`rocketpy.Flight` level: + +- ``Flight.stability_margin`` evaluates the **linear** margin along the realized + Mach and time — smooth, conventional, and the source of + ``initial_stability_margin`` / ``out_of_rail_stability_margin`` / + ``min_stability_margin`` / ``max_stability_margin``; +- ``Flight.realized_stability_margin`` evaluates the **nonlinear** CP at the + realized :math:`\alpha,\beta,M,Re`, falling back to the linear margin only at + negligible dynamic pressure (rail, rest, apogee), where the realized incidence + is meaningless. + +A positive margin (stability reference behind the center of mass) is the classic +passive-stability condition. + +Layer 4 — Dynamic stability +=========================== + +A static margin only gives the *sign* of the restoring moment. The actual +attitude response is the linearized pitch (or yaw) oscillator + +.. math:: + + I_L\,\ddot\theta + C_2\,\dot\theta + C_1\,\theta = 0 + +with the **corrective moment coefficient** (restoring moment per radian), + +.. math:: + + C_1 = \bar q\, A_\text{ref}\, C_{N,\alpha}\, (z_\text{cm} - x_\text{AC}), + +the **damping moment coefficient** (aerodynamic plus jet damping), + +.. math:: + + C_2 = \tfrac{1}{2}\rho V A_\text{ref} \sum_i k_i\,C_{N,\alpha,i}\,(x_i - z_\text{cm})^2 + \;+\; \dot m\,(x_\text{nozzle} - z_\text{cm})^2, + +and the lateral moment of inertia about the instantaneous center of mass +:math:`I_L`. From these, + +.. math:: + + \omega_n = \sqrt{C_1/I_L}, \qquad \zeta = \frac{C_2}{2\sqrt{C_1\,I_L}}. + +These are exposed on :class:`rocketpy.Flight` as +``corrective_moment_coefficient``, ``damping_moment_coefficient``, +``pitch_natural_frequency``, ``pitch_damping_ratio`` and the ``yaw_*`` +counterparts. :math:`\zeta < 1` is an underdamped (oscillatory) response; +RocketPy also exposes the empirical FFT ``attitude_frequency_response`` as a +cross-check. + +.. note:: + **Roll has no natural frequency.** A conventional rocket has no aerodynamic + roll-restoring moment, so roll is *neutrally stable* (a first-order system: + fin-cant forcing balanced by roll damping, spinning up to a steady rate). + The roll-pitch/yaw coupling of concern is **roll resonance** ("roll + lock-in"): when the roll rate crosses the pitch/yaw natural frequency, the + spin couples into the attitude oscillation and the amplitude can diverge. + ``Flight.plots.dynamic_stability_data`` therefore overlays the roll rate (as + a frequency) on the natural-frequency plot — the crossings are the points to + watch. + +Quick reference +=============== + +.. list-table:: + :header-rows: 1 + :widths: 32 18 50 + + * - Attribute + - Variables + - Meaning + * - ``Rocket.aerodynamic_center`` (``_yaw``) + - :math:`M` + - Linear (small-incidence) center of pressure; static-margin reference. + ``cp_position`` is a deprecated alias. + * - ``Rocket.center_of_pressure(α, β, M, Re)`` + - :math:`\alpha,\beta,M,Re` + - Nonlinear CP at finite incidence; combines both planes. + * - ``Rocket.center_of_pressure_over_{alpha,beta}`` + - :math:`\alpha` / :math:`\beta` + - CP travel sweep (design time). + * - ``Rocket.aerodynamic_coefficients(α, β, M, Re)`` + - :math:`\alpha,\beta,M,Re` + - Total :math:`C_N`, :math:`C_m` about the center of dry mass. + * - ``Rocket.static_margin`` (``_yaw``) + - :math:`t` + - Linear margin at :math:`M=0` (calibers). + * - ``Rocket.stability_margin`` (``_yaw``) + - :math:`M, t` + - Linear margin vs Mach and time (calibers). + * - ``Rocket.stability_margin_over_{alpha,beta}`` + - :math:`\alpha` / :math:`\beta` + - Nonlinear margin travel sweep (design time). + * - ``Flight.stability_margin`` + - :math:`t` + - Linear margin along the realized Mach(t) — smooth. + * - ``Flight.realized_stability_margin`` + - :math:`t` + - Nonlinear margin at the realized incidence. + * - ``Flight.{pitch,yaw}_natural_frequency`` + - :math:`t` + - Attitude oscillation natural frequency :math:`\omega_n`. + * - ``Flight.{pitch,yaw}_damping_ratio`` + - :math:`t` + - Attitude oscillation damping ratio :math:`\zeta`. + * - ``Flight.{corrective,damping}_moment_coefficient`` + - :math:`t` + - Oscillator coefficients :math:`C_1`, :math:`C_2`. + +Visualizing stability +===================== + +- ``Rocket.plots.stability_margin`` — linear margin vs Mach and time (surface). +- ``Rocket.plots.stability_margin_over_alpha`` / ``_over_beta`` — nonlinear + margin travel with incidence (yaw sweep shown when non-axisymmetric). +- ``Rocket.plots.aerodynamic_coefficients`` — :math:`C_N`, :math:`C_m` vs + :math:`\alpha`; ``drag_curves`` for :math:`C_D` vs Mach. +- ``Flight.plots.stability_and_control_data`` — linear and realized margin vs + time, plus the FFT frequency response. +- ``Flight.plots.dynamic_stability_data`` — natural frequency and damping ratio + vs time (pitch and yaw). + +For non-axisymmetric rockets, ``Rocket.plots.all`` / ``Rocket.all_info`` also +draw both the pitch (``xz``) and yaw (``yz``) planes and the yaw-plane margins. + +.. _barrowman_mapping: + +Mapping Barrowman surfaces to coefficients +========================================== + +The geometric surfaces expose a lift-curve slope :math:`C_{N,\alpha}(M)` +(``clalpha``), a geometric cp :math:`z_\text{cp}` and — for fins — roll +forcing/damping. These are translated into the linear coefficient model: + +.. math:: + + C_{L,\alpha} = C_{N,\alpha}, \qquad + C_{Q,\beta} = -C_{N,\alpha} + +.. math:: + + C_{m,\alpha} = -C_{N,\alpha}\,\frac{z_\text{cp}}{L_\text{ref}}, \qquad + C_{n,\beta} = +C_{N,\alpha}\,\frac{z_\text{cp}}{L_\text{ref}} + +For an **individual fin** at angular position :math:`\phi`, the lift only resists +incidence in its own plane, so its slope is projected onto the two planes — +:math:`\sin^2\phi` to the pitch plane and :math:`\cos^2\phi` to the yaw plane. +An evenly spaced set of :math:`n` fins sums to :math:`n/2` in each plane, +reproducing the axisymmetric fin-set result; a one-plane layout (e.g. canards at +:math:`0^\circ/180^\circ`) makes the pitch- and yaw-plane aerodynamic centers +differ. + +For fin sets, the cant-angle roll forcing and roll damping add + +.. math:: + + C_{l,0} = C_{lf,\delta}(M)\,\delta, \qquad + C_{l,p} = C_{ld,\omega}(M) + +where :math:`\delta` is the cant angle. With this mapping the geometric surfaces +reproduce the Barrowman lift and roll behavior while flowing through the same +generic coefficient path as every other surface. + +.. note:: + The independent :math:`\alpha,\ \beta` decomposition of the linear model + coincides with the classical single-plane Barrowman projection to first + order and diverges only at large combined angle of attack, where the + underlying linear coefficients are themselves no longer valid; the nonlinear + :meth:`rocketpy.Rocket.center_of_pressure` captures that regime. + +References +========== + +The Barrowman method and its coefficients are described in [Barrowman]_ and +[Niskanen]_. The dynamic-stability oscillator (corrective and damping moment +coefficients, natural frequency and damping ratio) follows [Niskanen]_. See also +the :ref:`individual_fins` and roll-moment technical documents for the fin +derivations. diff --git a/docs/technical/aerodynamics/elliptical_fins.rst b/docs/technical/aerodynamics/elliptical_fins.rst index 5ff5c4ee9..3b1cb113d 100644 --- a/docs/technical/aerodynamics/elliptical_fins.rst +++ b/docs/technical/aerodynamics/elliptical_fins.rst @@ -1,14 +1,3 @@ -========================= -Elliptical Fins Equations -========================= - -:Author: Mateus Stano Junqueira, -:Author: Franz Masatoshi Yuri, -:Author: Kaleb Ramos Wanderley Santos, -:Author: Matheus Gonçalvez Doretto, -:Date: February 2022 - - Nomenclature ============ diff --git a/docs/technical/aerodynamics/individual_fins.rst b/docs/technical/aerodynamics/individual_fins.rst index 408352e16..d8cb7a100 100644 --- a/docs/technical/aerodynamics/individual_fins.rst +++ b/docs/technical/aerodynamics/individual_fins.rst @@ -4,9 +4,6 @@ Individual Fin Model ==================== -:Author: Mateus Stano Junqueira -:Date: March 2025 - Introduction ============ @@ -491,8 +488,3 @@ rocket: # Angle of sideslip test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) - - - - - diff --git a/docs/technical/aerodynamics/roll_equations.rst b/docs/technical/aerodynamics/roll_equations.rst index 8ad106b60..b35f1de68 100644 --- a/docs/technical/aerodynamics/roll_equations.rst +++ b/docs/technical/aerodynamics/roll_equations.rst @@ -1,11 +1,3 @@ -======================================= -Roll equations for high-powered rockets -======================================= - -:Author: Bruno Abdulklech Sorban, -:Author: Mateus Stano Junqueira -:Date: February 2022 - Nomenclature ============ @@ -236,25 +228,3 @@ For the damping moment lift coefficient derivative: .. math:: (C_{lf\delta})_{K_{f}} = K_{f} \cdot C_{lf\delta} .. math:: (C_{ld\omega})_{K_{d}} = K_{d} \cdot C_{ld\omega} - -Comments -======== - -Roll moment is expected to increase linearly with velocity. This -relationship can be verified in the rotation frequency equilibrium -equation, described by [Niskanen]_ in equation -(3.73), and again stated below: - -.. math:: f_{eq} = \frac{\omega}{2\pi} = \frac{A_{ref}\beta \overline{Y_t} (C_{N\alpha})_1 }{4\pi^2 \sum_{i} c_i \xi^2 \Delta \xi} \, \delta V_0 - -The auxiliary value :math:`\beta` is defined as: -:math:`\beta = \sqrt{|1-M|}`, where M is the speed of the rocket in -Mach. - -.. .. math:: k = 1 + \frac{\frac{\sqrt{s^2-r_{t}^2}\Bigl(2C_{r}r_{t}^2\ln\Bigl(\frac{2s\sqrt{s^2-r_{t}^2}+2s^2}{r_{t}}\Bigr)-2C_{r}r_{t}^2\ln\Bigl(2s\Bigr)\Bigr)+2C_{r}s^3-{\pi}C_{r}r_{t}s^2-2C_{r}r_{t}^2s+{\pi}C_{r}r_{t}^3}{2r_{t}s^3-2r_{t}^3s}}{C_{r}\cdot\Bigl(\dfrac{s^2}{3}+\dfrac{{\pi}r_{t}s}{4}\Bigr)} - -.. .. math:: - -.. k = 1 + \frac{\sqrt{s^2-r_{t}^2}\Bigl(2r_{t}^2\ln\Bigl(\frac{2s\sqrt{s^2-r_{t}^2}+2s^2}{r_{t}}\Bigr)-2r_{t}^2\ln\Bigl(2s\Bigr)\Bigr)+2s^3-{\pi}r_{t}s^2-2r_{t}^2s+{\pi}r_{t}^3} -.. {(2r_{t}s^3-2r_{t}^3s) \cdot\Bigl(\dfrac{s^2}{3}+\dfrac{{\pi}r_{t}s}{4}\Bigr)} - diff --git a/docs/technical/index.rst b/docs/technical/index.rst index bb49ac56c..050c366e0 100644 --- a/docs/technical/index.rst +++ b/docs/technical/index.rst @@ -12,6 +12,7 @@ in their code. Equations of Motion v0 Equations of Motion v1 + Aerodynamics, Center of Pressure and Margins Elliptical Fins Individual Fin Roll Moment diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index ae602d784..42024fb9d 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -30,6 +30,7 @@ AeroSurface, AirBrakes, Components, + ControllableGenericSurface, EllipticalFin, EllipticalFins, Fin, diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index e9b30da45..fe9eec783 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -40,6 +40,80 @@ class for more information on how this plot is made. """ self.aero_surface.cl() + # Coefficients swept against their most relevant incidence angle: pitch-plane + # coefficients vs. angle of attack, yaw-plane ones vs. sideslip. + _COEFFICIENT_SWEEP = [ + ("cL", "alpha"), + ("cQ", "beta"), + ("cD", "alpha"), + ("cm", "alpha"), + ("cn", "beta"), + ] + + def coefficients(self, *, mach=0.3, angle_range_deg=15.0, filename=None): + """Plot the surface's main aerodynamic coefficients. + + Each available, non-zero coefficient (``cL, cQ, cD, cm, cn``) is swept + against its most relevant incidence angle (pitch-plane coefficients vs. + angle of attack, yaw-plane vs. sideslip) at a representative Mach, + skipping coefficients that are identically zero or flat. Works + uniformly across surface types because every generic, linear and + Barrowman surface now exposes these coefficients as callables over the + standard argument tuple. + + Parameters + ---------- + mach : float, optional + Mach number at which to sample the coefficients. Default 0.3. + angle_range_deg : float, optional + Half-range of the incidence sweep, in degrees. Default 15. + filename : str | None, optional + Path to save the figure; if None the figure is shown. + """ + surface = self.aero_surface + independent_vars = getattr(surface, "independent_vars", None) + if independent_vars is None: + return + index = {name: i for i, name in enumerate(independent_vars)} + if "mach" not in index: + return + n_args = len(independent_vars) + + angles = np.linspace( + np.deg2rad(-angle_range_deg), np.deg2rad(angle_range_deg), 61 + ) + entries = [] + for name, var in self._COEFFICIENT_SWEEP: + coeff = getattr(surface, name, None) + if coeff is None or getattr(coeff, "is_zero", False): + continue + if var not in index: + continue + values = np.empty_like(angles) + for i, angle in enumerate(angles): + args = [0.0] * n_args + args[index["mach"]] = mach + args[index[var]] = angle + values[i] = coeff(*args) + if np.allclose(values, 0.0): + continue + entries.append((name, var, values)) + + if not entries: + return + + fig, axes = plt.subplots( + len(entries), 1, figsize=(7, 2.3 * len(entries)), squeeze=False + ) + for ax, (name, var, values) in zip(axes[:, 0], entries): + ax.plot(np.rad2deg(angles), values) + ax.set_xlabel(f"{var.replace('_', ' ').title()} (°)") + ax.set_ylabel(name) + ax.grid(True) + axes[0, 0].set_title(f"{surface.name} coefficients (Mach {mach})") + plt.tight_layout() + show_or_save_plot(filename) + def all(self): """Plots all aero surface plots. @@ -49,6 +123,7 @@ def all(self): """ self.draw() self.lift() + self.coefficients() class _NoseConePlots(_AeroSurfacePlots): @@ -215,6 +290,7 @@ def all(self, *, filename=None): self.airfoil(filename=filename) self.roll(filename=filename) self.lift(filename=filename) + self.coefficients(filename=filename) class _FinPlots(_AeroSurfacePlots): @@ -295,6 +371,7 @@ def all(self, *, filename=None): self.airfoil(filename=filename) self.roll(filename=filename) self.lift(filename=filename) + self.coefficients(filename=filename) class _TrapezoidalFinsPlots(_FinsPlots): @@ -878,9 +955,18 @@ class _GenericSurfacePlots(_AeroSurfacePlots): def draw(self, *, filename=None): pass + def all(self): + """Plots all generic surface plots (the aerodynamic coefficients).""" + self.coefficients() + class _LinearGenericSurfacePlots(_AeroSurfacePlots): """Class that contains all linear generic surface plots.""" def draw(self, *, filename=None): pass + + def all(self): + """Plots all linear generic surface plots (the aerodynamic + coefficients).""" + self.coefficients() diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index e792acc9f..681d08b13 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -1264,12 +1264,30 @@ def stability_and_control_data(self, *, filename=None): # pylint: disable=too-m plt.figure(figsize=(9, 6)) + asymmetric = not self.flight.rocket.is_axisymmetric ax1 = plt.subplot(211) - ax1.plot(self.flight.stability_margin[:, 0], self.flight.stability_margin[:, 1]) + ax1.plot( + self.flight.stability_margin[:, 0], + self.flight.stability_margin[:, 1], + label="Linear pitch" if asymmetric else "Linear (aerodynamic center)", + ) + if asymmetric: + ax1.plot( + self.flight.stability_margin_yaw[:, 0], + self.flight.stability_margin_yaw[:, 1], + label="Linear yaw", + ) + ax1.plot( + self.flight.realized_stability_margin[:, 0], + self.flight.realized_stability_margin[:, 1], + label="Realized (nonlinear CP)", + linestyle="--", + ) ax1.set_title("Stability Margin") ax1.set_xlabel("Time (s)") ax1.set_ylabel("Stability Margin (c)") ax1.set_xlim(0, self.first_parachute_event_time) + ax1.legend() ax1.grid() self._add_event_markers_dropline(ax1, labels={"Burnout"}) @@ -1313,6 +1331,76 @@ def stability_and_control_data(self, *, filename=None): # pylint: disable=too-m plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) + def dynamic_stability_data(self, *, filename=None): + """Plots the rocket's dynamic-stability quantities over the flight: the + pitch (and, for non-axisymmetric rockets, yaw) natural frequency and + damping ratio of the linearized attitude oscillation. + + The roll rate is overlaid on the natural-frequency plot (as a frequency). + Roll is neutrally stable -- it has no restoring moment and therefore no + natural frequency of its own -- but **roll resonance** ("roll lock-in") + occurs where the roll rate crosses the pitch/yaw natural frequency, the + roll-pitch/yaw coupling driving the attitude oscillation. Those crossings + are the points to watch. + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + asymmetric = not self.flight.rocket.is_axisymmetric + upper = self.first_parachute_event_time + + plt.figure(figsize=(9, 6)) + + ax1 = plt.subplot(211) + freq = self.flight.pitch_natural_frequency + ax1.plot(freq[:, 0], freq[:, 1] / (2 * np.pi), label="Pitch natural freq.") + if asymmetric: + yaw_freq = self.flight.yaw_natural_frequency + ax1.plot( + yaw_freq[:, 0], yaw_freq[:, 1] / (2 * np.pi), "--", + label="Yaw natural freq.", + ) + # Roll rate as a frequency: where it crosses the natural frequency the + # rocket is in roll resonance (roll-pitch/yaw coupling). + roll_rate = self.flight.w3 + ax1.plot( + roll_rate[:, 0], np.abs(roll_rate[:, 1]) / (2 * np.pi), ":", + color="tab:red", label="Roll rate (resonance if crossing)", + ) + ax1.set_title("Natural Frequency & Roll Rate") + ax1.set_xlabel("Time (s)") + ax1.set_ylabel("Frequency (Hz)") + ax1.set_xlim(0, upper) + ax1.legend() + ax1.grid() + self._add_event_markers_dropline(ax1, labels={"Burnout"}) + + ax2 = plt.subplot(212) + ratio = self.flight.pitch_damping_ratio + ax2.plot(ratio[:, 0], ratio[:, 1], label="Pitch") + if asymmetric: + yaw_ratio = self.flight.yaw_damping_ratio + ax2.plot(yaw_ratio[:, 0], yaw_ratio[:, 1], "--", label="Yaw") + ax2.axhline(1.0, color="gray", linestyle=":", label="Critical (ζ=1)") + ax2.set_title("Damping Ratio") + ax2.set_xlabel("Time (s)") + ax2.set_ylabel("Damping Ratio (ζ)") + ax2.set_xlim(0, upper) + ax2.legend() + ax2.grid() + + plt.subplots_adjust(hspace=0.5) + show_or_save_plot(filename) + def pressure_rocket_altitude(self, *, filename=None): """Plots out pressure at rocket's altitude. @@ -1662,6 +1750,19 @@ def _ylim_in_range(arr): top = float(np.percentile(vals, 95)) * 1.5 return max(top, 1.0) + def _ylim_signed(arr): + # Symmetric y-limits for signed quantities (partial angle of attack, + # sideslip): these are arctan2-based and routinely go negative, so a + # 0 lower bound would clip half the signal. Scale by the 95th + # percentile of the magnitude to ignore the runaway rise near apogee. + mask = (arr[:, 0] >= t_lower) & (arr[:, 0] <= t_upper) + vals = arr[mask, 1] + if len(vals) == 0: + return (-10.0, 10.0) + top = float(np.percentile(np.abs(vals), 95)) * 1.5 + top = max(top, 1.0) + return (-top, top) + plt.figure(figsize=(9, 9)) ax1 = plt.subplot(311) @@ -1679,7 +1780,8 @@ def _ylim_in_range(arr): self.flight.partial_angle_of_attack[:, 1], ) ax2.set_xlim(t_lower, t_upper) - ax2.set_ylim(0, _ylim_in_range(self.flight.partial_angle_of_attack[:, :])) + ax2.set_ylim(*_ylim_signed(self.flight.partial_angle_of_attack[:, :])) + ax2.axhline(0, color="0.6", linewidth=0.8) ax2.set_title("Partial Angle of Attack") ax2.set_xlabel("Time (s)") ax2.set_ylabel("Partial Angle of Attack (°)") @@ -1690,7 +1792,8 @@ def _ylim_in_range(arr): self.flight.angle_of_sideslip[:, 0], self.flight.angle_of_sideslip[:, 1] ) ax3.set_xlim(t_lower, t_upper) - ax3.set_ylim(0, _ylim_in_range(self.flight.angle_of_sideslip[:, :])) + ax3.set_ylim(*_ylim_signed(self.flight.angle_of_sideslip[:, :])) + ax3.axhline(0, color="0.6", linewidth=0.8) ax3.set_title("Angle of Sideslip") ax3.set_xlabel("Time (s)") ax3.set_ylabel("Angle of Sideslip (°)") @@ -1751,6 +1854,7 @@ def all(self): # pylint: disable=too-many-statements print("\n\nTrajectory Stability and Control Plots\n") self.stability_and_control_data() + self.dynamic_stability_data() if self.flight.sensors: print("\n\nSensor Data Plots\n") diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 47da8a78b..561732534 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -1,3 +1,5 @@ +import os + import matplotlib.pyplot as plt import numpy as np @@ -87,6 +89,114 @@ def stability_margin(self): alpha=1, ) + def static_margin_yaw(self, *, filename=None): + """Plots the yaw-plane static margin of the rocket as a function of + time. Only meaningful for non-axisymmetric rockets; for an axisymmetric + rocket it is identical to :meth:`static_margin`. + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + self.rocket.static_margin_yaw(filename=filename) + + def stability_margin_yaw(self): + """Plots the yaw-plane stability margin of the rocket as a function of + Mach number and time. Only meaningful for non-axisymmetric rockets; for + an axisymmetric rocket it is identical to :meth:`stability_margin`. + + Returns + ------- + None + """ + self.rocket.stability_margin_yaw.plot_2d( + lower=0, + upper=[2, self.rocket.motor.burn_out_time], # Mach 2 and burnout + samples=[20, 20], + disp_type="surface", + alpha=1, + ) + + def stability_margin_over_alpha(self, *, filename=None): + """Plots the stability margin in calibers as a function of angle of + attack, for a range of Mach numbers. Built on the nonlinear center of + pressure, it shows how the margin changes with incidence -- the + angle-of-attack analogue of the static margin, which a Barrowman + (Mach-only) estimate cannot capture. Evaluated at the loaded center of + mass (``time = 0``). + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + alphas_deg = np.linspace(0, 15, 50) + alphas_rad = np.radians(alphas_deg) + + _, ax = plt.subplots() + for mach in (0.1, 0.5, 0.8, 1.2, 2.0): + margin = self.rocket.stability_margin_over_alpha(mach=mach) + margin_values = [margin.get_value_opt(a) for a in alphas_rad] + ax.plot(alphas_deg, margin_values, label=f"Mach {mach}") + + ax.set_title("Stability Margin vs Angle of Attack (loaded)") + ax.set_xlabel("Angle of Attack (deg)") + ax.set_ylabel("Stability Margin (c)") + ax.legend(loc="best", shadow=True) + plt.grid(True) + show_or_save_plot(filename) + + def stability_margin_over_beta(self, *, filename=None): + """Plots the stability margin in calibers as a function of sideslip + angle, for a range of Mach numbers -- the yaw-plane companion to + :meth:`stability_margin_over_alpha`. Most informative for + non-axisymmetric rockets, whose yaw-plane center of pressure differs + from the pitch-plane one. Evaluated at the loaded center of mass + (``time = 0``). + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + betas_deg = np.linspace(0, 15, 50) + betas_rad = np.radians(betas_deg) + + _, ax = plt.subplots() + for mach in (0.1, 0.5, 0.8, 1.2, 2.0): + margin = self.rocket.stability_margin_over_beta(mach=mach) + margin_values = [margin.get_value_opt(b) for b in betas_rad] + ax.plot(betas_deg, margin_values, label=f"Mach {mach}") + + ax.set_title("Stability Margin vs Sideslip Angle (loaded)") + ax.set_xlabel("Sideslip Angle (deg)") + ax.set_ylabel("Stability Margin (c)") + ax.legend(loc="best", shadow=True) + plt.grid(True) + show_or_save_plot(filename) + # pylint: disable=too-many-statements def drag_curves(self, *, filename=None): """Plots power off and on drag curves of the rocket as a function of time. @@ -141,6 +251,52 @@ def drag_curves(self, *, filename=None): plt.grid(True) show_or_save_plot(filename) + def aerodynamic_coefficients(self, *, filename=None): + """Plots the rocket's total aerodynamic coefficients -- normal force + ``C_N`` and pitch moment ``C_m`` (about the center of dry mass) -- versus + angle of attack, for a range of Mach numbers. The drag coefficient versus + Mach is shown by :meth:`drag_curves`. + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + alphas_deg = np.linspace(0, 15, 40) + alphas_rad = np.radians(alphas_deg) + + _, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5)) + for mach in (0.1, 0.5, 0.8, 1.2, 2.0): + coeffs = [ + self.rocket.aerodynamic_coefficients(a, 0.0, mach) + for a in alphas_rad + ] + ax1.plot( + alphas_deg, [c["normal_force"] for c in coeffs], label=f"Mach {mach}" + ) + ax2.plot( + alphas_deg, [c["pitch_moment"] for c in coeffs], label=f"Mach {mach}" + ) + + ax1.set_title("Normal Force Coefficient") + ax1.set_xlabel("Angle of Attack (deg)") + ax1.set_ylabel(r"$C_N$") + ax1.legend(loc="best", shadow=True) + ax1.grid(True) + ax2.set_title("Pitch Moment Coefficient (about CDM)") + ax2.set_xlabel("Angle of Attack (deg)") + ax2.set_ylabel(r"$C_m$") + ax2.grid(True) + plt.tight_layout() + show_or_save_plot(filename) + def thrust_to_weight(self): """ Plots the motor thrust force divided by rocket weight as a function of time. @@ -198,7 +354,34 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): "line_width": 1.0, } - _, ax = plt.subplots(figsize=(8, 6), facecolor=vis_args["background"]) + # A non-axisymmetric rocket looks different in the xz and yz planes + # (e.g. canards or fins present in one plane only), so draw both for + # comparison; an axisymmetric rocket looks the same in either plane. + planes = [plane] if self.rocket.is_axisymmetric else ["xz", "yz"] + + # Each plane is drawn in its own figure so the projections can be read + # and saved independently. When saving multiple planes, the plane name + # is appended to the filename (e.g. ``rocket_xz.png``). + for draw_plane in planes: + _, ax = plt.subplots( + figsize=(8, 6), + facecolor=vis_args["background"], + ) + self._draw_on_plane(ax, vis_args, draw_plane) + plt.tight_layout() + show_or_save_plot(self.__plane_filename(filename, draw_plane, planes)) + + @staticmethod + def __plane_filename(filename, plane, planes): + """Inserts the plane name before the extension when more than one plane + is drawn so each figure is saved to a distinct file.""" + if filename is None or len(planes) == 1: + return filename + root, ext = os.path.splitext(filename) + return f"{root}_{plane}{ext}" + + def _draw_on_plane(self, ax, vis_args, plane): + """Draws the rocket onto a single axis for the given projection plane.""" ax.set_aspect("equal") ax.grid(True, linestyle="--", linewidth=0.5) @@ -210,17 +393,17 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): last_radius, last_x = self._draw_tubes(ax, drawn_surfaces, vis_args) self._draw_motor(last_radius, last_x, ax, vis_args) self._draw_rail_buttons(ax, vis_args) - self._draw_center_of_mass_and_pressure(ax) + self._draw_center_of_mass_and_pressure(ax, plane) self._draw_sensors(ax, self.rocket.sensors, plane) - plt.title("Rocket Representation") - plt.xlim() - plt.ylim([-self.rocket.radius * 4, self.rocket.radius * 6]) - plt.xlabel("Position (m)") - plt.ylabel("Radius (m)") - plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left") - plt.tight_layout() - show_or_save_plot(filename) + title = "Rocket Representation" + if not self.rocket.is_axisymmetric: + title += f" ({plane} plane)" + ax.set_title(title) + ax.set_ylim([-self.rocket.radius * 4, self.rocket.radius * 6]) + ax.set_xlabel("Position (m)") + ax.set_ylabel("Radius (m)") + ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left") def __validate_aerodynamic_surfaces(self, plane): if not self.rocket.aerodynamic_surfaces: @@ -632,17 +815,59 @@ def _draw_rail_buttons(self, ax, vis_args): except IndexError: pass - def _draw_center_of_mass_and_pressure(self, ax): - """Draws the center of mass and center of pressure of the rocket.""" + def _draw_center_of_mass_and_pressure(self, ax, plane="xz"): + """Draws the center of mass and center of pressure of the rocket. + + The red dot is the (linear) aerodynamic center, conventionally labeled + the center of pressure. A translucent red band through it shows the + range over which the *nonlinear* center of pressure travels as the + incidence angle grows (angle of attack in the xz plane, sideslip in the + yz plane). + """ # Draw center of mass and center of pressure cm = self.rocket.center_of_mass(0) ax.scatter(cm, 0, color="#1565c0", label="Center of Mass", s=10) - cp = self.rocket.cp_position(0) + cp = self.rocket.aerodynamic_center(0) + + # Center of pressure travel band: sweep the nonlinear center of + # pressure over the relevant incidence angle and shade its min-max span. + cp_min, cp_max = self._center_of_pressure_range(plane) + if cp_max > cp_min: + ax.plot( + [cp_min, cp_max], + [0, 0], + color="red", + alpha=0.3, + linewidth=4, + solid_capstyle="butt", + zorder=9, + label="Center of Pressure Range", + ) + ax.scatter( - cp, 0, label="Static Center of Pressure", color="red", s=10, zorder=10 + cp, 0, label="Center of Pressure", color="red", s=10, zorder=10 ) + def _center_of_pressure_range(self, plane, max_angle=np.deg2rad(15), samples=31): + """Min and max center-of-pressure position over an incidence sweep. + + Sweeps the angle of attack (xz plane) or sideslip (yz plane) from 0 to + ``max_angle`` using :meth:`Rocket.center_of_pressure_over_alpha` / + :meth:`Rocket.center_of_pressure_over_beta` and returns the extent of + the resulting center-of-pressure travel. + """ + if plane == "yz": + cp_travel = self.rocket.center_of_pressure_over_beta() + else: + cp_travel = self.rocket.center_of_pressure_over_alpha() + angles = np.linspace(0, max_angle, samples) + positions = np.array([cp_travel.get_value_opt(a) for a in angles]) + positions = positions[np.isfinite(positions)] + if len(positions) == 0: + return (0.0, 0.0) + return (float(positions.min()), float(positions.max())) + def _draw_sensors(self, ax, sensors, plane): """Draw the sensor as a small thick line at the position of the sensor, with a vector pointing in the direction normal of the sensor. Get the @@ -724,12 +949,20 @@ def all(self): print("Drag Plots") print("-" * 20) # Separator for Drag Plots self.drag_curves() + self.aerodynamic_coefficients() # Stability Plots print("\nStability Plots") print("-" * 20) # Separator for Stability Plots self.static_margin() self.stability_margin() + self.stability_margin_over_alpha() + # Non-axisymmetric rockets: the above describe the pitch plane only, so + # also show the yaw-plane margins (including the sideslip sweep). + if not self.rocket.is_axisymmetric: + self.static_margin_yaw() + self.stability_margin_yaw() + self.stability_margin_over_beta() # Thrust-to-Weight Plot print("\nThrust-to-Weight Plot") diff --git a/rocketpy/prints/aero_surface_prints.py b/rocketpy/prints/aero_surface_prints.py index cc36f1b01..d85b6e243 100644 --- a/rocketpy/prints/aero_surface_prints.py +++ b/rocketpy/prints/aero_surface_prints.py @@ -1,11 +1,52 @@ from abc import ABC, abstractmethod +import numpy as np + # TODO: the rocketpy/prints/aero_surface_prints.py file could be separated into different, smaller files. class _AeroSurfacePrints(ABC): def __init__(self, aero_surface): self.aero_surface = aero_surface + def coefficients(self): + """Prints a summary of the surface's main aerodynamic coefficients. + + For every non-zero coefficient (``cL, cQ, cD, cm, cn``) reports its + value at a reference condition (5° angle of attack and sideslip, Mach + 0.3) and, when available, the variables it depends on. Works across all + surface types that expose the uniform coefficient accessors. + """ + surface = self.aero_surface + independent_vars = getattr(surface, "independent_vars", None) + if independent_vars is None: + return + index = {name: i for i, name in enumerate(independent_vars)} + if "mach" not in index: + return + n_args = len(independent_vars) + + print("Aerodynamic coefficients (AoA 5°, sideslip 5°, Mach 0.3):") + print("---------------------------------------------------------") + args = [0.0] * n_args + args[index["mach"]] = 0.3 + if "alpha" in index: + args[index["alpha"]] = np.deg2rad(5) + if "beta" in index: + args[index["beta"]] = np.deg2rad(5) + printed = False + for name in ("cL", "cQ", "cD", "cm", "cn"): + coeff = getattr(surface, name, None) + if coeff is None or getattr(coeff, "is_zero", False): + continue + value = coeff(*args) + depends = getattr(coeff, "depends_on", None) + suffix = f" [depends on {', '.join(depends)}]" if depends else "" + print(f" {name} = {value:.4f}{suffix}") + printed = True + if not printed: + print(" (all zero)") + print() + def identity(self): """Prints the identity of the aero surface. @@ -51,6 +92,7 @@ def all(self): self.identity() self.geometry() self.lift() + self.coefficients() class _NoseConePrints(_AeroSurfacePrints): @@ -343,8 +385,8 @@ class _GenericSurfacePrints(_AeroSurfacePrints): def geometry(self): print("Geometric information of the Surface:") print("----------------------------------") - print(f"Reference Area: {self.generic_surface.reference_area:.3f} m") - print(f"Reference length: {2 * self.generic_surface.rocket_radius:.3f} m") + print(f"Reference Area: {self.aero_surface.reference_area:.3f} m^2") + print(f"Reference length: {self.aero_surface.reference_length:.3f} m\n") def all(self): """Prints all information of the generic surface. @@ -355,7 +397,7 @@ def all(self): """ self.identity() self.geometry() - self.lift() + self.coefficients() class _LinearGenericSurfacePrints(_AeroSurfacePrints): @@ -364,8 +406,8 @@ class _LinearGenericSurfacePrints(_AeroSurfacePrints): def geometry(self): print("Geometric information of the Surface:") print("----------------------------------") - print(f"Reference Area: {self.generic_surface.reference_area:.3f} m") - print(f"Reference length: {2 * self.generic_surface.rocket_radius:.3f} m") + print(f"Reference Area: {self.aero_surface.reference_area:.3f} m^2") + print(f"Reference length: {self.aero_surface.reference_length:.3f} m\n") def all(self): """Prints all information of the linear generic surface. @@ -376,4 +418,4 @@ def all(self): """ self.identity() self.geometry() - self.lift() + self.coefficients() diff --git a/rocketpy/prints/flight_prints.py b/rocketpy/prints/flight_prints.py index e28ed2a12..5ead46adc 100644 --- a/rocketpy/prints/flight_prints.py +++ b/rocketpy/prints/flight_prints.py @@ -631,6 +631,27 @@ def stability_margin(self): f"at {self.flight.min_stability_margin_time:.2f} s" ) + out_of_rail_time = self.flight.out_of_rail_time + # The margins above describe the pitch plane. For a non-axisymmetric + # rocket, also report the yaw-plane margin at rail departure. + if not self.flight.rocket.is_axisymmetric: + print( + "Out of Rail Stability Margin - yaw: " + f"{self.flight.stability_margin_yaw.get_value_opt(out_of_rail_time):.3f} c" + ) + + # Dynamic stability at rail departure (representative powered condition). + two_pi = 6.283185307179586 + natural_frequency = self.flight.pitch_natural_frequency.get_value_opt( + out_of_rail_time + ) + damping_ratio = self.flight.pitch_damping_ratio.get_value_opt(out_of_rail_time) + print( + f"Pitch Natural Frequency (out of rail): " + f"{natural_frequency / two_pi:.2f} Hz" + ) + print(f"Pitch Damping Ratio (out of rail): {damping_ratio:.3f}") + def all(self): """Prints out all data available about the Flight. This method invokes all other print methods in the class. diff --git a/rocketpy/prints/rocket_prints.py b/rocketpy/prints/rocket_prints.py index 7b768ea2f..72a2daf8e 100644 --- a/rocketpy/prints/rocket_prints.py +++ b/rocketpy/prints/rocket_prints.py @@ -126,7 +126,8 @@ def rocket_aerodynamics_quantities(self): f"Center of Mass position (time=0): {self.rocket.center_of_mass(0):.3f} m" ) print( - f"Center of Pressure position (time=0): {self.rocket.cp_position(0):.3f} m" + f"Aerodynamic Center position (Mach=0): " + f"{self.rocket.aerodynamic_center(0):.3f} m" ) print( f"Initial Static Margin (mach=0, time=0): " @@ -137,10 +138,28 @@ def rocket_aerodynamics_quantities(self): f"{self.rocket.static_margin(self.rocket.motor.burn_out_time):.3f} c" ) print( - f"Rocket Center of Mass (time=0) - Center of Pressure (mach=0): " - f"{abs(self.rocket.center_of_mass(0) - self.rocket.cp_position(0)):.3f} m\n" + f"Rocket Center of Mass (time=0) - Aerodynamic Center (Mach=0): " + f"{abs(self.rocket.center_of_mass(0) - self.rocket.aerodynamic_center(0)):.3f} m\n" ) + if not self.rocket.is_axisymmetric: + print( + "The rocket is NOT axisymmetric: the values above describe the " + "PITCH plane. Yaw plane:\n" + ) + print( + f"Aerodynamic Center position - yaw (Mach=0): " + f"{self.rocket.aerodynamic_center_yaw(0):.3f} m" + ) + print( + f"Initial Static Margin - yaw (mach=0, time=0): " + f"{self.rocket.static_margin_yaw(0):.3f} c" + ) + print( + f"Final Static Margin - yaw (mach=0, time=burn_out): " + f"{self.rocket.static_margin_yaw(self.rocket.motor.burn_out_time):.3f} c\n" + ) + def parachute_data(self): """Print parachute data. diff --git a/rocketpy/rocket/__init__.py b/rocketpy/rocket/__init__.py index afb7f0bb6..94ad5e8ee 100644 --- a/rocketpy/rocket/__init__.py +++ b/rocketpy/rocket/__init__.py @@ -2,6 +2,7 @@ from rocketpy.rocket.aero_surface import ( AeroSurface, AirBrakes, + ControllableGenericSurface, EllipticalFin, EllipticalFins, Fin, diff --git a/rocketpy/rocket/aero_surface/__init__.py b/rocketpy/rocket/aero_surface/__init__.py index 7634d3500..542435781 100644 --- a/rocketpy/rocket/aero_surface/__init__.py +++ b/rocketpy/rocket/aero_surface/__init__.py @@ -10,6 +10,9 @@ TrapezoidalFin, TrapezoidalFins, ) +from rocketpy.rocket.aero_surface.controllable_generic_surface import ( + ControllableGenericSurface, +) from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface from rocketpy.rocket.aero_surface.nose_cone import NoseCone diff --git a/rocketpy/rocket/aero_surface/_barrowman_surface.py b/rocketpy/rocket/aero_surface/_barrowman_surface.py new file mode 100644 index 000000000..9a9843193 --- /dev/null +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -0,0 +1,120 @@ +import numpy as np + +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient +from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface + + +class _BarrowmanSurface(LinearGenericSurface): + """Intermediate base for geometry-defined (Barrowman) aerodynamic surfaces + such as nose cones, tails/transitions and fin sets. + + These surfaces historically expose a lift-curve slope ``clalpha`` (a + ``Function`` of Mach), a geometric center of pressure ``cpz`` and, for fins, + a pair of roll forcing/damping coefficients. This class translates that + Barrowman description into the linear generic-surface coefficient model so + the forces and moments are computed by the single, shared + :meth:`GenericSurface.compute_forces_and_moments`: + + - normal-force slope -> ``cL_alpha`` (pitch plane) and ``cQ_beta`` (yaw plane); + - center-of-pressure offset -> ``cm_alpha`` / ``cn_beta`` (the moment is + carried by the coefficients, with the force applied at the surface origin); + - fin roll -> ``cl_0`` (cant forcing) and ``cl_p`` (roll damping). + + Subclasses must compute ``self.clalpha`` (Function of Mach) and the geometric + center of pressure before calling ``super().__init__`` (which passes the + geometric cp through ``center_of_pressure``), and, for fins, set + ``self.roll_parameters = [clf_delta, cld_omega, cant_angle_rad]``. + """ + + @staticmethod + def _beta(mach): + """Prandtl-Glauert compressibility factor used to correct subsonic + force coefficients of the nose cone, fins and tails/transitions, as in + Barrowman. + + Parameters + ---------- + mach : int, float + Mach number. + + Returns + ------- + beta : float + Compressibility factor based on the Mach number. + + References + ---------- + [1] Barrowman, James S. https://arc.aiaa.org/doi/10.2514/6.1979-504 + """ + if mach < 0.8: + return np.sqrt(1 - mach**2) + elif mach < 1.1: + return np.sqrt(1 - 0.8**2) + else: + return np.sqrt(mach**2 - 1) + + @property + def force_application_point(self): + """Barrowman surfaces apply the resultant force at the surface origin; + the whole center-of-pressure offset is carried by the ``cm``/``cn`` + moment coefficients (avoiding a double count with the ``cp ^ force`` + transport). The geometric center of pressure remains available through + ``self.cp``/``self.cpz`` for display and through + ``center_of_pressure_z`` as a mach-dependent diagnostic. + """ + return Vector([0, 0, 0]) + + def evaluate_coefficients(self): + """Populate the linear generic-surface coefficient derivatives from the + surface geometry. Called by ``GenericSurface.__init__`` and again + whenever the geometry changes. + """ + clalpha = self.clalpha # Function of Mach + cpz = self.cpz # geometric center of pressure (set from center_of_pressure) + reference_length = self.reference_length + + # Axisymmetric Barrowman lift: equal-magnitude slopes in the pitch and + # yaw planes. The yaw-plane (side-force) slope is opposite in sign due to + # the aerodynamic-to-body frame convention used by the shared compute. + self.cL_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach), "cL_alpha" + ) + self.cQ_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach), "cQ_beta" + ) + + # Center-of-pressure offset expressed as moment coefficients (the local + # cp ^ force couple, with the force applied at the origin). + self.cm_alpha = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach) * cpz / reference_length, + "cm_alpha", + ) + self.cn_beta = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach) * cpz / reference_length, + "cn_beta", + ) + + # Fin roll forcing (cant) and damping, when present. + roll_parameters = getattr(self, "roll_parameters", None) + if roll_parameters is not None: + clf_delta, cld_omega, cant_angle_rad = roll_parameters + self.cl_0 = self._mach_coefficient( + lambda mach: clf_delta.get_value_opt(mach) * cant_angle_rad, "cl_0" + ) + self.cl_p = self._mach_coefficient( + lambda mach: cld_omega.get_value_opt(mach), "cl_p" + ) + + def _mach_coefficient(self, func_of_mach, name="coefficient"): + """Wrap a Mach-only callable into an :class:`AeroCoefficient` that + depends only on Mach but is callable over the full coefficient argument + tuple. Storing it at one dimension keeps the Mach table un-smeared and + evaluates with a single argument in the hot loop. + """ + return AeroCoefficient( + func_of_mach, + depends_on=("mach",), + independent_vars=self.independent_vars, + name=name, + ) diff --git a/rocketpy/rocket/aero_surface/aero_coefficient.py b/rocketpy/rocket/aero_surface/aero_coefficient.py new file mode 100644 index 000000000..e65aa3a9a --- /dev/null +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -0,0 +1,229 @@ +"""Minimal-dimension aerodynamic coefficient storage. + +A :class:`AeroCoefficient` stores a single aerodynamic coefficient at its +*intrinsic* dimensionality - a constant, or a :class:`Function` over only the +variables the coefficient actually depends on (its ``depends_on``) - and maps +the full coefficient argument tuple (in ``independent_vars`` order) down to that +subset on every call. + +This avoids forcing a Mach-only (or constant) coefficient into a full seven +dimensional :class:`Function`: interpolation happens at the right dimension (so +a Mach-only table is not smeared across a 7-D domain) and evaluation passes only +the arguments that matter. It generalizes the per-call ``dict(zip(...))`` subset +selection that the CSV loader used to do inline. +""" + +import inspect + +from rocketpy.mathutils import Function + + +class AeroCoefficient: + """A single aerodynamic coefficient stored at minimal dimensionality. + + Parameters + ---------- + source : int, float, callable, or Function + The coefficient value. A number is stored as a constant; a callable or + :class:`Function` is stored over ``depends_on``. + depends_on : sequence of str + The independent variables the coefficient depends on, a (possibly + empty) subset of ``independent_vars``. The order is normalized to the + order of ``independent_vars``. + independent_vars : sequence of str + The full, ordered list of independent variables of the owning surface + (e.g. ``alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate`` + plus any control or unsteady axes). Defines the argument order accepted + by :meth:`__call__`. + name : str, optional + Name of the coefficient, used for the underlying ``Function`` output. + """ + + def __init__(self, source, depends_on, independent_vars, name="coefficient"): + self.name = name + self.independent_vars = tuple(independent_vars) + # ``depends_on`` is kept in the given order because it matches the + # positional argument order of the stored source (callable parameters, + # CSV columns, …). ``_indices`` therefore maps the full argument tuple + # to the source's own argument order. + self.depends_on = tuple(depends_on) + unknown = [var for var in self.depends_on if var not in self.independent_vars] + if unknown: + raise ValueError( + f"{name} depends on unknown variable(s) {unknown}; " + f"valid variables are {list(self.independent_vars)}." + ) + self._indices = tuple( + self.independent_vars.index(var) for var in self.depends_on + ) + + self.is_zero = False + self._constant = None + if isinstance(source, Function): + self.function = source + elif callable(source): + self.function = Function( + source, + list(self.depends_on) or ["x"], + [name], + interpolation="linear", + extrapolation="natural", + ) + else: + # Scalar constant. + self._constant = float(source) + self.is_zero = self._constant == 0.0 + self.function = Function(self._constant) + + self._evaluate = self.function.get_value_opt + + @classmethod + def from_input(cls, input_data, name, independent_vars, csv_loader=None): + """Build an :class:`AeroCoefficient` from a user coefficient input. + + Mirrors the accepted coefficient inputs of + :class:`GenericSurface`: a number, a callable, a :class:`Function`, or a + path to a CSV file, inferring ``depends_on`` from each. + + Parameters + ---------- + input_data : int, float, str, callable, or Function + The coefficient value (number, CSV path, callable, or Function). + name : str + Coefficient name, used for error messages and the Function output. + independent_vars : sequence of str + The owning surface's ordered independent variables. + csv_loader : callable, optional + Callable ``(file_path, name) -> (function, depends_on)`` used to + load a CSV coefficient at minimal dimension. Required when + ``input_data`` is a string path. + + Returns + ------- + AeroCoefficient + """ + independent_vars = list(independent_vars) + n_vars = len(independent_vars) + vars_repr = ", ".join(independent_vars) + + if isinstance(input_data, AeroCoefficient): + # Already an AeroCoefficient (e.g. a to_dict/from_dict round trip): + # re-key it to the requested independent-variable order. + return cls( + input_data._constant + if input_data._constant is not None + else input_data.function, + input_data.depends_on, + independent_vars, + name, + ) + + if isinstance(input_data, str): + if csv_loader is None: # pragma: no cover - defensive + raise ValueError("A csv_loader is required for CSV coefficients.") + function, depends_on = csv_loader(input_data, name) + return cls(function, depends_on, independent_vars, name) + + if isinstance(input_data, Function): + dom_dim = input_data.__dom_dim__ + if dom_dim == n_vars: + depends_on = independent_vars + elif dom_dim == 1: + # A 1-D Function is taken to depend on the first independent + # variable (alpha) unless its input name matches one of them. + depends_on = [cls._infer_single_var(input_data, independent_vars)] + else: + raise ValueError( + f"{name} Function must have {n_vars} input arguments " + f"({vars_repr}) or be one-dimensional." + ) + return cls(input_data, depends_on, independent_vars, name) + + if callable(input_data): + depends_on = cls._infer_callable_depends_on( + input_data, independent_vars, name + ) + return cls(input_data, depends_on, independent_vars, name) + + # Anything else must be a scalar number. + try: + float(input_data) + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid input for {name}: must be a number, a CSV file path, " + "a callable, or a Function." + ) from exc + return cls(input_data, (), independent_vars, name) + + @staticmethod + def _infer_single_var(function, independent_vars): + """Best-effort name of the variable a 1-D Function depends on.""" + try: + label = function.__inputs__[0] + except (AttributeError, IndexError, TypeError): + return independent_vars[0] + label_lower = str(label).lower() + for var in independent_vars: + if var in label_lower: + return var + return independent_vars[0] + + @staticmethod + def _infer_callable_depends_on(func, independent_vars, name): + """Infer ``depends_on`` for a plain callable. + + Two conventions are accepted, checked in order: + + 1. *Named subset* - every parameter name is an independent variable, so + the parameters themselves name the dependency subset (e.g. + ``lambda alpha, mach: ...``). + 2. *Positional full-arity* - the parameter count equals the number of + independent variables, so the callable depends on all of them + regardless of how its parameters are named (e.g. + ``lambda a, b, m, r, p, q, rr: ...``). + """ + n_vars = len(independent_vars) + try: + params = list(inspect.signature(func).parameters.values()) + except (TypeError, ValueError): # pragma: no cover - builtins + params = [] + names = [p.name for p in params] + + if names and set(names) <= set(independent_vars): + return names + if len(names) == n_vars: + return list(independent_vars) + raise ValueError( + f"{name} callable must accept {n_vars} positional arguments " + f"({', '.join(independent_vars)}) or name its parameters after the " + "independent variables it depends on." + ) + + @property + def is_zero_coefficient(self): + """Back-compat alias used by the linear model's hot-loop term skipping.""" + return self.is_zero + + @property + def __dom_dim__(self): + """Number of full independent variables (the call arity).""" + return len(self.independent_vars) + + def get_value_opt(self, *args): + """Fast, unvalidated evaluation (mirrors :meth:`Function.get_value_opt`). + + Maps the full ``independent_vars`` argument tuple down to the source's + own ``depends_on`` arguments before evaluating; a constant short-circuits. + """ + if self._constant is not None: + return self._constant + return self._evaluate(*(args[i] for i in self._indices)) + + # Calling the coefficient is the same as the fast evaluator; the linear + # model grabs ``get_value_opt`` directly for the hot loop. + __call__ = get_value_opt + + def __repr__(self): + if self._constant is not None: + return f"AeroCoefficient({self.name}={self._constant})" + return f"AeroCoefficient({self.name}, depends_on={self.depends_on})" diff --git a/rocketpy/rocket/aero_surface/aero_surface.py b/rocketpy/rocket/aero_surface/aero_surface.py index 6727476c6..f2a561885 100644 --- a/rocketpy/rocket/aero_surface/aero_surface.py +++ b/rocketpy/rocket/aero_surface/aero_surface.py @@ -1,159 +1,46 @@ -from abc import ABC, abstractmethod +import warnings +from abc import ABC -import numpy as np +from rocketpy.rocket.aero_surface.generic_surface import GenericSurface -from rocketpy.mathutils.vector_matrix import Matrix +_AEROSURFACE_DEPRECATION_MESSAGE = ( + "`AeroSurface` is deprecated and will be removed in a future major " + "release. RocketPy's aerodynamic surfaces now derive from `GenericSurface`; " + "use `GenericSurface` (or a concrete surface class such as `NoseCone`, " + "`TrapezoidalFins`, `Tail`, ...) instead. Note that `isinstance(surface, " + "AeroSurface)` still returns True for all surfaces." +) class AeroSurface(ABC): - """Abstract class used to define aerodynamic surfaces.""" - - def __init__(self, name, reference_area, reference_length): - self.reference_area = reference_area - self.reference_length = reference_length - self.name = name - - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - - self._rotation_surface_to_body = Matrix( - [ - [-1, 0, 0], - [0, 1, 0], - [0, 0, -1], - ] + """Deprecated base class for aerodynamic surfaces. + + .. deprecated:: + ``AeroSurface`` is no longer the base of RocketPy's aerodynamic + surfaces, which now all derive from :class:`GenericSurface`. It is kept + only as a deprecated compatibility shim and will be removed in a future + major release. Importing, instantiating or subclassing it emits a + ``DeprecationWarning``. + + For backward compatibility, :class:`GenericSurface` (and therefore every + concrete surface) is registered as a *virtual subclass*, so existing + ``isinstance(surface, AeroSurface)`` and + ``issubclass(type(surface), AeroSurface)`` checks keep working. + """ + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + warnings.warn( + _AEROSURFACE_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2 ) - @staticmethod - def _beta(mach): - """Defines a parameter that is often used in aerodynamic - equations. It is commonly used in the Prandtl factor which - corrects subsonic force coefficients for compressible flow. - This is applied to the lift coefficient of the nose cone, - fins and tails/transitions as in [1]. - - Parameters - ---------- - mach : int, float - Number of mach. - - Returns - ------- - beta : int, float - Value that characterizes flow speed based on the mach number. - - References - ---------- - [1] Barrowman, James S. https://arc.aiaa.org/doi/10.2514/6.1979-504 - """ - - if mach < 0.8: - return np.sqrt(1 - mach**2) - elif mach < 1.1: - return np.sqrt(1 - 0.8**2) - else: - return np.sqrt(mach**2 - 1) - - @abstractmethod - def evaluate_center_of_pressure(self): - """Evaluates the center of pressure of the aerodynamic surface in local - coordinates. - - Returns - ------- - None - """ - - @abstractmethod - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the aerodynamic surface. - - Returns - ------- - None - """ - - @abstractmethod - def evaluate_geometrical_parameters(self): - """Evaluates the geometrical parameters of the aerodynamic surface. - - Returns - ------- - None - """ - - @abstractmethod - def info(self): - """Prints and plots summarized information of the aerodynamic surface. - - Returns - ------- - None - """ - - @abstractmethod - def all_info(self): - """Prints and plots all the available information of the aero surface. - - Returns - ------- - None - """ - - def compute_forces_and_moments( - self, - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - *args, - ): # pylint: disable=unused-argument - """Computes the forces and moments acting on the aerodynamic surface. - Used in each time step of the simulation. This method is valid for - the barrowman aerodynamic models. + def __init__(self, *args, **kwargs): # pylint: disable=unused-argument + warnings.warn( + _AEROSURFACE_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2 + ) - Parameters - ---------- - stream_velocity : tuple - Tuple containing the stream velocity components in the body frame. - stream_speed : int, float - Speed of the stream in m/s. - stream_mach : int, float - Mach number of the stream. - rho : int, float - Density of the stream in kg/m^3. - cp : Vector - Center of pressure coordinates in the body frame. - args : tuple - Additional arguments. - kwargs : dict - Additional keyword arguments. - Returns - ------- - tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. - """ - R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0 - cpz = cp[2] - stream_vx, stream_vy, stream_vz = stream_velocity - if stream_vx**2 + stream_vy**2 != 0: - # Normalize component stream velocity in body frame - stream_vzn = stream_vz / stream_speed - if -1 * stream_vzn < 1: - attack_angle = np.arccos(-stream_vzn) - c_lift = self.cl.get_value_opt(attack_angle, stream_mach) - # Component lift force magnitude - lift = 0.5 * rho * (stream_speed**2) * self.reference_area * c_lift - # Component lift force components - lift_dir_norm = (stream_vx**2 + stream_vy**2) ** 0.5 - lift_xb = lift * (stream_vx / lift_dir_norm) - lift_yb = lift * (stream_vy / lift_dir_norm) - # Total lift force - R1, R2, R3 = lift_xb, lift_yb, 0 - # Total moment - M1, M2, M3 = -cpz * lift_yb, cpz * lift_xb, 0 - return R1, R2, R3, M1, M2, M3 +# Register GenericSurface (and thus all concrete surfaces) as a virtual +# subclass so that ``isinstance(surface, AeroSurface)`` remains True during the +# deprecation period. Virtual registration does not trigger ``__init_subclass__``. +AeroSurface.register(GenericSurface) diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index e7417aaa9..3c4958255 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -6,12 +6,14 @@ from rocketpy.plots.aero_surface_plots import _AirBrakesPlots from rocketpy.prints.aero_surface_prints import _AirBrakesPrints -from .aero_surface import AeroSurface +from .controllable_generic_surface import ControllableGenericSurface # TODO: review airbrakes implementation to make it more in line with events -class AirBrakes(AeroSurface): - """AirBrakes class. Inherits from AeroSurface. +class AirBrakes(ControllableGenericSurface): + """AirBrakes class. Inherits from :class:`ControllableGenericSurface`, using + ``deployment_level`` as its single control variable and a multivariable drag + coefficient. Attributes ---------- @@ -100,45 +102,66 @@ def __init__( ------- None """ - super().__init__(name, reference_area, None) + self.clamp = clamp + self.override_rocket_drag = override_rocket_drag + self.initial_deployment_level = deployment_level self.drag_coefficient_curve = drag_coefficient_curve - # TODO: this drag coefficient needs to be a function of more parameters - # just like generic surface coefficients + # Back-compatible 2-input (deployment level, Mach) drag curve, kept for + # display/serialization and as the source of the multivariable drag + # coefficient below. self.drag_coefficient = Function( drag_coefficient_curve, inputs=["Deployment Level", "Mach"], outputs="Drag Coefficient", interpolation="linear", ) - self.clamp = clamp - self.override_rocket_drag = override_rocket_drag - self.initial_deployment_level = deployment_level + + # Multivariable drag coefficient over the generic-surface inputs plus the + # ``deployment_level`` control axis. The deployment-0 ⇒ Cd 0 rule applies + # only when the air brakes add to (rather than override) the rocket drag. + def drag_coefficient_function( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate, deployment_level + ): # pylint: disable=unused-argument + if deployment_level == 0 and not self.override_rocket_drag: + return 0.0 + return self.drag_coefficient.get_value_opt(deployment_level, mach) + + super().__init__( + reference_area=reference_area, + reference_length=2 * (reference_area / np.pi) ** 0.5, + coefficients={"cD": drag_coefficient_function}, + center_of_pressure=(0, 0, 0), + name=name, + controls=("deployment_level",), + ) + self.deployment_level = deployment_level self.prints = _AirBrakesPrints(self) self.plots = _AirBrakesPlots(self) - @property - def deployment_level(self): - """Returns the deployment level of the air brakes.""" - return self._deployment_level - - @deployment_level.setter - def deployment_level(self, value): - # Check if deployment level is within bounds and warn user if not - if value < 0 or value > 1: - # Clamp deployment level if clamp is True + def _clamp_control(self, name, value): + """Clamp ``deployment_level`` to ``[0, 1]`` (or warn if ``clamp`` is + False), preserving the historical AirBrakes behavior.""" + if name == "deployment_level" and (value < 0 or value > 1): if self.clamp: - # Make sure deployment level is between 0 and 1 - value = np.clip(value, 0, 1) + value = float(np.clip(value, 0, 1)) else: - # Raise warning if clamp is False warnings.warn( f"Deployment level of {self.name} is smaller than 0 or " + "larger than 1. Extrapolation for the drag coefficient " + "curve will be used.", UserWarning, ) - self._deployment_level = value + return value + + @property + def deployment_level(self): + """Returns the deployment level of the air brakes.""" + return self.control_state["deployment_level"] + + @deployment_level.setter + def deployment_level(self, value): + self.set_control("deployment_level", value) def _reset(self): """Resets the air brakes to their initial state. This is ran at the @@ -146,44 +169,6 @@ def _reset(self): state.""" self.deployment_level = self.initial_deployment_level - def evaluate_center_of_pressure(self): - """Evaluates the center of pressure of the aerodynamic surface in local - coordinates. - - For air brakes, all components of the center of pressure position are - 0. - - Returns - ------- - None - """ - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - self.cp = (self.cpx, self.cpy, self.cpz) - - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the aerodynamic surface. - - For air brakes, the current model assumes no lift is generated. - Therefore, the lift coefficient (C_L) and its derivative relative to the - angle of attack (C_L_alpha), is 0. - - Returns - ------- - None - """ - self.clalpha = Function( - lambda mach: 0, - "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: 0, - ["Alpha (rad)", "Mach"], - "Lift Coefficient", - ) - def evaluate_geometrical_parameters(self): """Evaluates the geometrical parameters of the aerodynamic surface. diff --git a/rocketpy/rocket/aero_surface/controllable_generic_surface.py b/rocketpy/rocket/aero_surface/controllable_generic_surface.py new file mode 100644 index 000000000..42e473eb9 --- /dev/null +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -0,0 +1,162 @@ +from rocketpy.plots.aero_surface_plots import _GenericSurfacePlots +from rocketpy.prints.aero_surface_prints import _GenericSurfacePrints +from rocketpy.rocket.aero_surface.generic_surface import ( + BASE_INDEPENDENT_VARS, + GenericSurface, +) + + +class ControllableGenericSurface(GenericSurface): + """A generic aerodynamic surface whose coefficients additionally depend on + one or more **control-deflection** variables (canards, grid fins, elevons, + air-brake deployment, …) sourced at runtime from a controller. + + On top of the seven standard independent variables of + :class:`GenericSurface` (``alpha``, ``beta``, ``mach``, ``reynolds``, + ``pitch_rate``, ``yaw_rate``, ``roll_rate``), the coefficient functions take + one extra argument per entry of ``controls`` (appended in order). The + current control values are held in :attr:`control_state` and mutated each + simulation step by a controller (see ``Rocket.add_controllable_surface``); + :meth:`_coefficient_arguments` appends them to every coefficient evaluation. + + Attributes + ---------- + ControllableGenericSurface.control_variables : list of str + Names of the control-deflection axes, in coefficient-argument order. + ControllableGenericSurface.control_state : dict + Current value of each control variable (defaults to 0). + """ + + def __init__( + self, + reference_area, + reference_length, + coefficients, + center_of_pressure=(0, 0, 0), + name="Controllable Generic Surface", + controls=("deflection",), + ): + """Create a controllable generic aerodynamic surface. + + Parameters + ---------- + reference_area : int, float + Reference area of the surface, in squared meters. + reference_length : int, float + Reference length of the surface, in meters. + coefficients : dict + Aerodynamic coefficients (``cL``, ``cQ``, ``cD``, ``cm``, ``cn``, + ``cl``), each a callable/CSV/Function of the seven base variables + **plus** the control variables listed in ``controls`` (appended in + order). Omitted coefficients default to 0. + center_of_pressure : tuple, list, optional + Application point of the aerodynamic forces and moments in the local + surface frame. Default ``(0, 0, 0)``. + name : str, optional + Name of the surface. Default ``"Controllable Generic Surface"``. + controls : iterable of str, optional + Names of the control-deflection axes. Default ``("deflection",)``. + Each name becomes an extra coefficient argument and a key in + :attr:`control_state`. + """ + # These must be set before ``super().__init__`` so coefficient + # processing (arity, CSV validation) and the derived-cp accessors see + # the extended variable list and the current control values. + self.control_variables = list(controls) + self.independent_vars = BASE_INDEPENDENT_VARS + self.control_variables + self.control_state = {name: 0.0 for name in self.control_variables} + + super().__init__( + reference_area=reference_area, + reference_length=reference_length, + coefficients=coefficients, + center_of_pressure=center_of_pressure, + name=name, + ) + + self.prints = _GenericSurfacePrints(self) + self.plots = _GenericSurfacePlots(self) + + def _coefficient_arguments( + self, + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + alpha_dot=0.0, + beta_dot=0.0, + ): + """Append the current control-variable values (in + ``self.control_variables`` order) to the standard inputs (which may + already include the unsteady ``alpha_dot``/``beta_dot`` axes).""" + base = super()._coefficient_arguments( + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + alpha_dot, + beta_dot, + ) + controls = tuple(self.control_state[name] for name in self.control_variables) + return base + controls + + def _clamp_control(self, name, value): # pylint: disable=unused-argument + """Hook to constrain a control value before it is stored. The base class + applies no clamping; subclasses (e.g. ``AirBrakes``) may override.""" + return value + + def set_control(self, name, value): + """Set the current value of a control variable (applying any clamping). + + Parameters + ---------- + name : str + Name of the control variable; must be one of + :attr:`control_variables`. + value : float + New control value. + """ + if name not in self.control_state: + raise KeyError( + f"Unknown control variable '{name}'. " + f"Valid controls are: {self.control_variables}." + ) + self.control_state[name] = self._clamp_control(name, value) + + def get_control(self, name): + """Return the current value of a control variable.""" + return self.control_state[name] + + def to_dict(self, include_outputs=False): # pylint: disable=unused-argument + return { + "reference_area": self.reference_area, + "reference_length": self.reference_length, + "coefficients": { + "cL": self.cL, + "cQ": self.cQ, + "cD": self.cD, + "cm": self.cm, + "cn": self.cn, + "cl": self.cl, + }, + "center_of_pressure": self.center_of_pressure, + "name": self.name, + "controls": self.control_variables, + } + + @classmethod + def from_dict(cls, data): + return cls( + reference_area=data["reference_area"], + reference_length=data["reference_length"], + coefficients=data["coefficients"], + center_of_pressure=data.get("center_of_pressure", (0, 0, 0)), + name=data.get("name", "Controllable Generic Surface"), + controls=data.get("controls", ("deflection",)), + ) diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py index f6b09f797..332326aea 100644 --- a/rocketpy/rocket/aero_surface/fins/_base_fin.py +++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py @@ -5,13 +5,15 @@ from rocketpy.mathutils.function import Function -from ..aero_surface import AeroSurface +from .._barrowman_surface import _BarrowmanSurface +from ..linear_generic_surface import LinearGenericSurface -class _BaseFin(AeroSurface): +class _BaseFin(_BarrowmanSurface): """ Base class for fins, shared by both Fin and Fins classes. - Inherits from AeroSurface. + Inherits from :class:`_BarrowmanSurface`, translating the fin geometry into + the linear generic-surface coefficient model. Handles shared initialization logic and common properties. """ @@ -47,8 +49,13 @@ def __init__( self.geometry = None self.reference_area = np.pi * rocket_radius**2 + self.reference_length = self.rocket_diameter - super().__init__(name, self.reference_area, self.rocket_diameter) + # The linear generic-surface machinery is initialized lazily by + # ``_finalize_barrowman`` once the concrete subclass has set up its + # geometry strategy and the first ``_update_geometry_chain`` has + # produced ``clalpha``, ``cpz`` and ``roll_parameters``. + self._barrowman_initialized = False def _update_reference_quantities(self): """Update quantities that depend on rocket radius.""" @@ -56,11 +63,32 @@ def _update_reference_quantities(self): self.reference_length = self.rocket_diameter def _update_geometry_chain(self): - """Update geometry-dependent quantities in dependency order.""" + """Update geometry-dependent quantities in dependency order, then + (re)build the generic-surface coefficients from the new geometry.""" self.evaluate_geometrical_parameters() self.evaluate_center_of_pressure() self.evaluate_lift_coefficient() self.evaluate_roll_parameters() + if self._barrowman_initialized: + # Geometry changed after construction: refresh the coefficients. + self.evaluate_coefficients() + self.compute_all_coefficients() + self._evaluate_derived_coefficients() + else: + self._finalize_barrowman() + + def _finalize_barrowman(self): + """Initialize the linear generic-surface machinery from the geometry + computed by the first ``_update_geometry_chain`` call.""" + LinearGenericSurface.__init__( + self, + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=self.name, + ) + self._barrowman_initialized = True @property def rocket_radius(self): diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py index 5fec8a099..ac49d92e7 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -148,6 +148,21 @@ def __init__( self._angular_position = angular_position self._angular_position_rad = math.radians(angular_position) + def _update_geometry_chain(self): + """Run the base geometry/coefficient chain, then (re)build the body<->fin + rotation matrices. + + The rotation matrices must be set **after** the chain: the chain's first + call initializes the generic-surface machinery, which resets + ``_rotation_surface_to_body`` to the identity. Doing it here (rather than + in each concrete fin's ``__init__``) ensures every individual-fin + subclass -- trapezoidal, elliptical and free-form -- gets correct, + angular-position-aware rotation matrices on construction and whenever the + geometry changes. + """ + super()._update_geometry_chain() + self.evaluate_rotation_matrix() + @property def cant_angle(self): return self._cant_angle @@ -318,6 +333,41 @@ def evaluate_rotation_matrix(self): self._rotation_fin_to_body = R_body_to_fin.transpose self._rotation_surface_to_body = self._rotation_fin_to_body + @property + def force_application_point(self): + """A single (off-axis) fin keeps its bespoke force computation and + transports the moment geometrically through its center of pressure, + so the force application point is the fin's actual cp rather than the + surface origin used by axisymmetric Barrowman surfaces. + """ + return Vector([self.cpx, self.cpy, self.cpz]) + + def evaluate_coefficients(self): + """A single fin transports its moment geometrically (via ``cp ^ force`` + in its own ``compute_forces_and_moments``), so only the normal-force + slopes are exposed for the stability-margin diagnostic; the moment + coefficients stay zero to avoid double-counting the cp offset. + + A fin's lift only resists incidence in its own plane, so its slope is + projected onto the pitch and yaw planes by its angular position + ``phi``: ``sin(phi)**2`` to the pitch plane (``cL_alpha``) and + ``cos(phi)**2`` to the yaw plane (``cQ_beta``). A fin at ``phi = 0`` + (lying in the yaw plane) thus feeds the yaw plane only, which is what + makes a non-axisymmetric individual-fin layout report different pitch- + and yaw-plane centers of pressure. An evenly spaced set of ``n`` fins + sums to ``n / 2`` in each plane, reproducing the axisymmetric ``Fins`` + set (see :meth:`Fins.fin_num_correction`). + """ + clalpha = self.clalpha + sin_sq = math.sin(self.angular_position_rad) ** 2 + cos_sq = math.cos(self.angular_position_rad) ** 2 + self.cL_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach) * sin_sq + ) + self.cQ_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach) * cos_sq + ) + def compute_forces_and_moments( self, stream_velocity, diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index b61bfcc19..9913b3f5b 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -200,11 +200,18 @@ def evaluate_roll_parameters(self): """ clf_delta = ( self.roll_forcing_interference_factor - * self.fin_num_correction(self.n) + * self.n * (self.Yma + self.rocket_radius) * self.clalpha_single_fin / self.reference_length ) # Function of mach number + # NOTE: roll forcing scales with the full fin count ``n`` -- every + # identically-canted fin contributes the same roll moment, with no + # cancellation. This differs from the normal-force slope, which uses the + # ``fin_num_correction(n)`` (~n/2) multiple-fin factor because fins at + # different roll angles partially cancel in pitch/yaw. Using + # ``fin_num_correction(n)`` here previously halved the roll forcing (and + # the roll rate) of a fin set relative to the equivalent individual fins. clf_delta.set_inputs("Mach") clf_delta.set_outputs("Roll moment forcing coefficient derivative") clf_delta.set_title( @@ -251,66 +258,6 @@ def fin_num_correction(n): else: return n / 2 - def compute_forces_and_moments( - self, - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - omega, - *args, - ): # pylint: disable=arguments-differ - """Computes the forces and moments acting on the aerodynamic surface. - - Parameters - ---------- - stream_velocity : tuple of float - The velocity of the airflow relative to the surface. - stream_speed : float - The magnitude of the airflow speed. - stream_mach : float - The Mach number of the airflow. - rho : float - Air density. - cp : Vector - Center of pressure coordinates in the body frame. - omega: tuple[float, float, float] - Tuple containing angular velocities around the x, y, z axes. - - Returns - ------- - tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. - """ - - R1, R2, R3, M1, M2, _ = super().compute_forces_and_moments( - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - ) - clf_delta, cld_omega, cant_angle_rad = self.roll_parameters - M3_forcing = ( - (1 / 2 * rho * stream_speed**2) - * self.reference_area - * self.reference_length - * clf_delta.get_value_opt(stream_mach) - * cant_angle_rad - ) - M3_damping = ( - (1 / 2 * rho * stream_speed) - * self.reference_area - * (self.reference_length) ** 2 - * cld_omega.get_value_opt(stream_mach) - * omega[2] - / 2 - ) - M3 = M3_forcing + M3_damping - return R1, R2, R3, M1, M2, M3 - def to_dict(self, **kwargs): if self.airfoil: if kwargs.get("discretize", False): diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py index c58055945..f6bf1a7cd 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py @@ -163,7 +163,6 @@ def __init__( ) self._update_geometry_chain() self.evaluate_shape() - self.evaluate_rotation_matrix() self.prints = _TrapezoidalFinPrints(self) self.plots = _TrapezoidalFinPlots(self) diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index 4b83e0e4f..90c0a3537 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -6,6 +6,20 @@ from rocketpy.mathutils import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient + +# Single source of truth for the coefficient independent variables. Subclasses +# (e.g. ControllableGenericSurface, or the alpha_dot/beta_dot extension) append +# extra axes to this base via ``self.independent_vars``. +BASE_INDEPENDENT_VARS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", +] class GenericSurface: @@ -21,6 +35,7 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Surface", + unsteady_aero=False, ): """Create a generic aerodynamic surface, defined by its aerodynamic coefficients. This surface is used to model any aerodynamic surface @@ -74,8 +89,27 @@ def __init__( aerodynamic surface. The default value is (0, 0, 0). name : str, optional Name of the aerodynamic surface. Default is 'GenericSurface'. + unsteady_aero : bool, optional + If True, the coefficients additionally depend on the time + derivatives of the flow angles, and ``alpha_dot`` and ``beta_dot`` + are appended (in that order) to the independent variables. CSV files + may then include "alpha_dot"/"beta_dot" columns, and callables must + accept the two extra trailing arguments. The simulation supplies 0 + for these unless it computes them, so existing coefficient tables are + unaffected. Default is False. """ + # Independent variables the coefficients depend on. Subclasses may set + # this (with extra axes appended) before calling ``super().__init__``. + # When ``unsteady_aero`` is enabled, the time-derivatives of the flow + # angles (``alpha_dot``, ``beta_dot``) are appended as extra axes + # (defaulting to 0 at runtime, so existing tables are unaffected). + self._unsteady_aero = unsteady_aero + if not hasattr(self, "independent_vars"): + self.independent_vars = list(BASE_INDEPENDENT_VARS) + if unsteady_aero: + self.independent_vars += ["alpha_dot", "beta_dot"] + self.reference_area = reference_area self.reference_length = reference_length self.center_of_pressure = center_of_pressure @@ -94,6 +128,144 @@ def __init__( value = self._process_input(coeff_value, coeff) setattr(self, coeff, value) + self.evaluate_coefficients() + self._evaluate_derived_coefficients() + + @property + def force_application_point(self): + """Local point (surface frame) at which the resultant force is applied + when transporting its moment to the rocket's center of dry mass. For a + plain generic surface this is simply the center of pressure ``self.cp``; + the residual couple is carried by the ``cm``/``cn``/``cl`` coefficients. + Barrowman subclasses override this to the origin, because they fold the + whole cp offset into the moment coefficients instead. + """ + return Vector([self.cpx, self.cpy, self.cpz]) + + def evaluate_coefficients(self): + """Hook for subclasses to (re)populate the aerodynamic coefficient + ``Function``s from their geometry. The base class builds coefficients + directly from the user-provided dictionary, so this is a no-op here. + Subclasses that derive coefficients from geometry (e.g. the Barrowman + surfaces) override this and call it again whenever their geometry + changes. + + Returns + ------- + None + """ + + def _evaluate_derived_coefficients(self): + """Build the mach-only diagnostic accessors used by the rocket's + center-of-pressure / stability-margin computation, for both the pitch + and the yaw plane. + + These reconstruct, at the linearization point ``alpha = beta = 0`` with + zero rates, each plane's force-curve slope and the location of its + center of pressure. The center of pressure combines the surface's + declared local ``cpz`` with the offset implied by its moment + coefficient (the two representations are interchangeable; + ``cpz_eff = cpz - (dc_moment/dangle)/(dc_force/dangle) * L_ref``): + + - pitch plane: ``lift_coefficient_derivative`` (``dcL/dalpha``) and + ``center_of_pressure_z`` (from ``cm``); + - yaw plane: ``side_coefficient_derivative`` and + ``center_of_pressure_z_yaw`` (from ``cn``). + + Returns + ------- + None + """ + cL_alpha = self._partial_slope(self.cL, axis="alpha") + cm_alpha = self._partial_slope(self.cm, axis="alpha") + cQ_beta = self._partial_slope(self.cQ, axis="beta") + cn_beta = self._partial_slope(self.cn, axis="beta") + self._set_derived_cp_accessors(cL_alpha, cm_alpha, cQ_beta, cn_beta) + + def _set_derived_cp_accessors(self, cL_alpha, cm_alpha, cQ_beta, cn_beta): + """Store the pitch- and yaw-plane diagnostic accessors as mach-only + ``Function``s, guarding the moment/force division for zero-force + surfaces (which then drop out of the force-weighted cp average). + + Parameters + ---------- + cL_alpha : Function + Pitch-plane normal-force slope ``dcL/dalpha`` vs. mach. + cm_alpha : Function + Pitch-moment slope ``dcm/dalpha`` vs. mach. + cQ_beta : Function + Yaw-plane side-force slope ``dcQ/dbeta`` vs. mach. + cn_beta : Function + Yaw-moment slope ``dcn/dbeta`` vs. mach. + """ + reference_length = self.reference_length + local_cpz = self.force_application_point[2] + + def _cp_z(force_slope, moment_slope): + def cp_z(mach): + slope = force_slope.get_value_opt(mach) + if slope == 0: + return local_cpz + return ( + local_cpz + - moment_slope.get_value_opt(mach) / slope * reference_length + ) + + return Function(cp_z, "Mach", "Center of pressure to local origin (m)") + + # Pitch plane. + self.lift_coefficient_derivative = cL_alpha + self.center_of_pressure_z = _cp_z(cL_alpha, cm_alpha) + + # Yaw plane. The side-force slope is sign-adjusted (``-cQ_beta``) so that + # an axisymmetric surface yields the same signed weight as the pitch + # plane, making the two planes' margins coincide when symmetric. + self.side_coefficient_derivative = -cQ_beta + self.center_of_pressure_z_yaw = _cp_z(cQ_beta, cn_beta) + + def _partial_slope(self, coefficient, axis): + """Partial derivative ``d(coefficient)/d(axis)`` at ``alpha = beta = 0`` + and zero rates, returned as a mach-only ``Function``. + + Reuses :meth:`Function.differentiate` on a single-variable slice of the + coefficient taken along ``axis`` (``"alpha"`` or ``"beta"``) with all + other base inputs frozen at zero. Extra axes (control deflections) are + frozen at their current value via :meth:`_coefficient_arguments`. + + Parameters + ---------- + coefficient : Function + A coefficient ``Function`` over ``self.independent_vars``. + axis : str + Either ``"alpha"`` or ``"beta"``. + + Returns + ------- + Function + ``d(coefficient)/d(axis)`` evaluated at the zero point, vs. mach. + """ + + def slope(mach): + if axis == "alpha": + sliced = Function( + lambda alpha: coefficient( + *self._coefficient_arguments( + alpha, 0.0, mach, 0.0, 0.0, 0.0, 0.0 + ) + ) + ) + else: + sliced = Function( + lambda beta: coefficient( + *self._coefficient_arguments( + 0.0, beta, mach, 0.0, 0.0, 0.0, 0.0 + ) + ) + ) + return sliced.differentiate(0) + + return Function(slope, "Mach", "Coefficient derivative") + def _get_default_coefficients(self): """Returns default coefficients @@ -173,6 +345,8 @@ def _compute_from_coefficients( pitch_rate, yaw_rate, roll_rate, + alpha_dot=0.0, + beta_dot=0.0, ): """Compute the aerodynamic forces and moments from the aerodynamic coefficients. @@ -208,30 +382,56 @@ def _compute_from_coefficients( dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area dyn_pressure_area_length = dyn_pressure_area * self.reference_length - # Compute aerodynamic forces - lift = dyn_pressure_area * self.cL( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - side = dyn_pressure_area * self.cQ( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - drag = dyn_pressure_area * self.cD( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + # Coefficient arguments (base 7 vars, plus any extra axes appended by + # subclasses such as control deflections or the unsteady alpha_dot/ + # beta_dot terms). + args = self._coefficient_arguments( + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + alpha_dot, + beta_dot, ) + # Compute aerodynamic forces + lift = dyn_pressure_area * self.cL(*args) + side = dyn_pressure_area * self.cQ(*args) + drag = dyn_pressure_area * self.cD(*args) + # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cm( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - yaw = dyn_pressure_area_length * self.cn( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - roll = dyn_pressure_area_length * self.cl( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + pitch = dyn_pressure_area_length * self.cm(*args) + yaw = dyn_pressure_area_length * self.cn(*args) + roll = dyn_pressure_area_length * self.cl(*args) return lift, side, drag, pitch, yaw, roll + def _coefficient_arguments( + self, + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + alpha_dot=0.0, + beta_dot=0.0, + ): + """Returns the argument tuple passed to every coefficient ``Function``, + in ``self.independent_vars`` order. The base class provides the seven + standard inputs, plus ``alpha_dot``/``beta_dot`` when ``unsteady_aero`` + is enabled. Subclasses (e.g. :class:`ControllableGenericSurface`) + override this to append further axes such as control deflections. + """ + base = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) + if self._unsteady_aero: + return base + (alpha_dot, beta_dot) + return base + def compute_forces_and_moments( self, stream_velocity, @@ -243,6 +443,8 @@ def compute_forces_and_moments( density, dynamic_viscosity, z, + alpha_dot=0.0, + beta_dot=0.0, ): """Computes the forces and moments acting on the aerodynamic surface. Used in each time step of the simulation. This method is valid for @@ -306,20 +508,29 @@ def compute_forces_and_moments( omega[0], # q omega[1], # r omega[2], # p + alpha_dot, + beta_dot, ) - # Conversion from aerodynamic frame to body frame + # Conversion from the aerodynamic frame to the body frame. This is the + # direction cosine matrix (DCM) that expresses the aerodynamic-frame + # force components in the body frame, i.e. rotations by ``-alpha`` about + # x and ``+beta`` about y. Using the opposite-sign "vector rotation" + # matrices is incorrect: it leaves the result effectively in the + # aerodynamic frame, flipping the transverse components of any force that + # has a drag part (see RocketPy issue #932). Surfaces with no drag (the + # Barrowman lift/side surfaces) differ only in the small axial term. rotation_matrix = Matrix( [ [1, 0, 0], - [0, math.cos(alpha), -math.sin(alpha)], - [0, math.sin(alpha), math.cos(alpha)], + [0, math.cos(alpha), math.sin(alpha)], + [0, -math.sin(alpha), math.cos(alpha)], ] ) @ Matrix( [ - [math.cos(beta), 0, -math.sin(beta)], + [math.cos(beta), 0, math.sin(beta)], [0, 1, 0], - [math.sin(beta), 0, math.cos(beta)], + [-math.sin(beta), 0, math.cos(beta)], ] ) R1, R2, R3 = rotation_matrix @ Vector([side, -lift, -drag]) @@ -330,91 +541,48 @@ def compute_forces_and_moments( return R1, R2, R3, M1, M2, M3 def _process_input(self, input_data, coeff_name): - """Process the input data, either as a CSV file or a callable function. + """Process a coefficient input into an :class:`AeroCoefficient`. + + Accepts a number, a callable, a :class:`Function`, or a path to a CSV + file, storing the coefficient at its intrinsic dimensionality (its + ``depends_on``) rather than forcing it into a full + ``len(self.independent_vars)``-D ``Function``. See + :class:`AeroCoefficient`. Parameters ---------- - input_data : str or callable - Input data to be processed, either a path to a CSV or a callable. + input_data : int, float, str, callable, or Function + Input data to be processed. coeff_name : str Name of the coefficient being processed for error reporting. Returns ------- - Function - Function object with 7 input arguments (alpha, beta, mach, reynolds, - pitch_rate, yaw_rate, roll_rate). + AeroCoefficient + Callable over the full ``self.independent_vars`` argument tuple. """ - if isinstance(input_data, str): - # Input is assumed to be a file path to a CSV - return self.__load_generic_surface_csv(input_data, coeff_name) - elif isinstance(input_data, Function): - if input_data.__dom_dim__ != 7: - raise ValueError( - f"{coeff_name} function must have 7 input arguments" - " (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)." - ) - return input_data - elif callable(input_data): - # Check if callable has 7 inputs (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - if input_data.__code__.co_argcount != 7: - raise ValueError( - f"{coeff_name} function must have 7 input arguments" - " (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)." - ) - return Function( - input_data, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) - elif input_data == 0: - return Function( - lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: 0, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) - else: - raise TypeError( - f"Invalid input for {coeff_name}: must be a CSV file path" - " or a callable." - ) + return AeroCoefficient.from_input( + input_data, + coeff_name, + self.independent_vars, + csv_loader=self.__load_generic_surface_csv, + ) def __load_generic_surface_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load GenericSurface coefficient CSV into a 7D Function. + """Load a GenericSurface coefficient CSV at minimal dimension. This loader expects header-based CSV data with one or more independent - variables among: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, - roll_rate. + variables among ``self.independent_vars`` (the seven base variables, + plus any extra axes added by subclasses such as control deflections). + + Returns + ------- + tuple + ``(function, depends_on)`` where ``function`` is a low-dimensional + ``Function`` over the present columns and ``depends_on`` lists those + columns. Consumed by :meth:`AeroCoefficient.from_input`. """ - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] + independent_vars = list(self.independent_vars) try: with open(file_path, mode="r") as file: @@ -464,23 +632,7 @@ def __load_generic_surface_csv(self, file_path, coeff_name): # pylint: disable= extrapolation="natural", ) - def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): - args_by_name = { - "alpha": alpha, - "beta": beta, - "mach": mach, - "reynolds": reynolds, - "pitch_rate": pitch_rate, - "yaw_rate": yaw_rate, - "roll_rate": roll_rate, - } - selected_args = [args_by_name[col] for col in ordered_present_columns] - return csv_func(*selected_args) - - return Function( - wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) + # The CSV columns may appear in any order; AeroCoefficient maps the full + # argument tuple to ``ordered_present_columns`` order, so the stored + # Function is queried directly at its own (minimal) dimensionality. + return csv_func, ordered_present_columns diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index 3e7ed9a55..fa085252c 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -171,6 +171,29 @@ def __init__( self.prints = _LinearGenericSurfacePrints(self) self.plots = _LinearGenericSurfacePlots(self) + def _evaluate_derived_coefficients(self): + """Exact override of the diagnostic cp accessors. The linear model + already exposes the forcing derivatives ``cL_alpha``/``cm_alpha`` (pitch) + and ``cQ_beta``/``cn_beta`` (yaw), so the slopes are read directly + (frozen at zero alpha/beta/rates) instead of being recovered by + numerical differentiation. Damping derivatives (``_p/_q/_r``) are + intentionally excluded from the stability cp. + """ + + def _at_zero(coefficient, name): + return Function( + lambda mach: coefficient(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0), + "Mach", + name, + ) + + self._set_derived_cp_accessors( + _at_zero(self.cL_alpha, "cL_alpha"), + _at_zero(self.cm_alpha, "cm_alpha"), + _at_zero(self.cQ_beta, "cQ_beta"), + _at_zero(self.cn_beta, "cn_beta"), + ) + def _get_default_coefficients(self): """Returns default coefficients @@ -220,64 +243,119 @@ def _get_default_coefficients(self): } return default_coefficients - def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): - """Compute the forcing coefficient from the derivatives of the - aerodynamic coefficients.""" - - def total_coefficient( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ): - return ( - c_0(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - + c_alpha(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * alpha - + c_beta(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * beta - ) + _COEFFICIENT_INPUTS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], - ) + def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): + """Compose the forcing coefficient ``c_0 + c_alpha*alpha + c_beta*beta``, + evaluating only the non-zero terms. + + Two hot-loop optimizations: ``get_value_opt`` is the unvalidated fast + evaluator (for callable-source coefficients it is the raw source), and + terms that are identically zero are skipped entirely. For a Barrowman + surface each forcing coefficient has at most one non-zero derivative, so + this typically collapses to a single source call (or to a constant 0). + """ + has_0 = not getattr(c_0, "is_zero_coefficient", False) + has_alpha = not getattr(c_alpha, "is_zero_coefficient", False) + has_beta = not getattr(c_beta, "is_zero_coefficient", False) + c_0_opt = c_0.get_value_opt + c_alpha_opt = c_alpha.get_value_opt + c_beta_opt = c_beta.get_value_opt + + if not (has_0 or has_alpha or has_beta): + + def total_coefficient( # pylint: disable=unused-argument + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + return 0.0 + + else: + + def total_coefficient( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + value = 0.0 + if has_0: + value += c_0_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + if has_alpha: + value += ( + c_alpha_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * alpha + ) + if has_beta: + value += ( + c_beta_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * beta + ) + return value + + return Function(total_coefficient, self._COEFFICIENT_INPUTS, ["coefficient"]) def compute_damping_coefficient(self, c_p, c_q, c_r): - """Compute the damping coefficient from the derivatives of the - aerodynamic coefficients.""" - - def total_coefficient( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ): - return ( - c_p(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * roll_rate - + c_q(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * pitch_rate - + c_r(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * yaw_rate - ) - - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], - ) + """Compose the damping coefficient + ``c_p*roll_rate + c_q*pitch_rate + c_r*yaw_rate``, evaluating only the + non-zero terms (see :meth:`compute_forcing_coefficient`). For a Barrowman + surface only ``cl_p`` (roll damping) is non-zero, so most damping + coefficients collapse to a constant 0. + """ + has_p = not getattr(c_p, "is_zero_coefficient", False) + has_q = not getattr(c_q, "is_zero_coefficient", False) + has_r = not getattr(c_r, "is_zero_coefficient", False) + c_p_opt = c_p.get_value_opt + c_q_opt = c_q.get_value_opt + c_r_opt = c_r.get_value_opt + + if not (has_p or has_q or has_r): + + def total_coefficient( # pylint: disable=unused-argument + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + return 0.0 + + else: + + def total_coefficient( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + value = 0.0 + if has_p: + value += ( + c_p_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * roll_rate + ) + if has_q: + value += ( + c_q_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * pitch_rate + ) + if has_r: + value += ( + c_r_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * yaw_rate + ) + return value + + return Function(total_coefficient, self._COEFFICIENT_INPUTS, ["coefficient"]) def compute_all_coefficients(self): """Compute all the aerodynamic coefficients from the derivatives.""" @@ -312,6 +390,29 @@ def compute_all_coefficients(self): ) self.cld = self.compute_damping_coefficient(self.cl_p, self.cl_q, self.cl_r) + self._expose_uniform_coefficients() + + def _expose_uniform_coefficients(self): + """Expose the main force/moment coefficients (``cL, cQ, cD, cm, cn``) as + the composed *forcing* coefficients, so every surface - including + Barrowman ones whose coefficients are derived from geometry - has + uniform, callable accessors over the standard argument tuple. + + The forcing coefficient is the static, flow-state part of the model + (``c_0 + c_alpha*alpha + c_beta*beta``); the rate-damping parts + (``cLd``, …) are dimensionally tied to the reduced rate and remain + separate. The roll coefficient is intentionally **not** exposed as + ``cl`` here: geometry-defined subclasses (nose cones, tails, individual + fins) use the legacy ``cl`` name for their *lift* coefficient. The + composed roll forcing/damping remain available as ``clf``/``cld``. + """ + # pylint: disable=invalid-name + self.cL = self.cLf + self.cQ = self.cQf + self.cD = self.cDf + self.cm = self.cmf + self.cn = self.cnf + def _compute_from_coefficients( self, rho, @@ -323,10 +424,15 @@ def _compute_from_coefficients( pitch_rate, yaw_rate, roll_rate, + alpha_dot=0.0, # pylint: disable=unused-argument + beta_dot=0.0, # pylint: disable=unused-argument ): """Compute the aerodynamic forces and moments from the aerodynamic coefficients. + The linear (Barrowman) model does not use the unsteady ``alpha_dot`` / + ``beta_dot`` terms; they are accepted for signature compatibility. + Parameters ---------- rho : float @@ -369,42 +475,36 @@ def _compute_from_coefficients( / 2 ) + # Evaluate the composed coefficients through the fast, unvalidated + # ``get_value_opt`` path (the composed coefficients are callable-source + # Functions, so this calls the closure directly, skipping the per-call + # ``__call__``/``get_value`` argument validation in the hot loop). + args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) + # Compute aerodynamic forces - lift = dyn_pressure_area * self.cLf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_damping * self.cLd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + lift = dyn_pressure_area * self.cLf.get_value_opt( + *args + ) + dyn_pressure_area_damping * self.cLd.get_value_opt(*args) - side = dyn_pressure_area * self.cQf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_damping * self.cQd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + side = dyn_pressure_area * self.cQf.get_value_opt( + *args + ) + dyn_pressure_area_damping * self.cQd.get_value_opt(*args) - drag = dyn_pressure_area * self.cDf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_damping * self.cDd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + drag = dyn_pressure_area * self.cDf.get_value_opt( + *args + ) + dyn_pressure_area_damping * self.cDd.get_value_opt(*args) # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cmf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cmd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + pitch = dyn_pressure_area_length * self.cmf.get_value_opt( + *args + ) + dyn_pressure_area_length_damping * self.cmd.get_value_opt(*args) - yaw = dyn_pressure_area_length * self.cnf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cnd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + yaw = dyn_pressure_area_length * self.cnf.get_value_opt( + *args + ) + dyn_pressure_area_length_damping * self.cnd.get_value_opt(*args) - roll = dyn_pressure_area_length * self.clf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cld( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + roll = dyn_pressure_area_length * self.clf.get_value_opt( + *args + ) + dyn_pressure_area_length_damping * self.cld.get_value_opt(*args) return lift, side, drag, pitch, yaw, roll diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py index 240a61a5c..a0c0507e7 100644 --- a/rocketpy/rocket/aero_surface/nose_cone.py +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -7,10 +7,10 @@ from rocketpy.plots.aero_surface_plots import _NoseConePlots from rocketpy.prints.aero_surface_prints import _NoseConePrints -from .aero_surface import AeroSurface +from ._barrowman_surface import _BarrowmanSurface -class NoseCone(AeroSurface): +class NoseCone(_BarrowmanSurface): """Keeps nose cone information. Note @@ -129,7 +129,9 @@ def __init__( # pylint: disable=too-many-statements None """ rocket_radius = rocket_radius or base_radius - super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + self.name = name + self.reference_area = np.pi * rocket_radius**2 + self.reference_length = 2 * rocket_radius self._rocket_radius = rocket_radius self._base_radius = base_radius @@ -163,6 +165,16 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_lift_coefficient() self.evaluate_center_of_pressure() + # Translate the Barrowman geometry (clalpha, cpz) into the linear + # generic-surface coefficient model and build the shared compute path. + super().__init__( + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=name, + ) + self.plots = _NoseConePlots(self) self.prints = _NoseConePrints(self) diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index 7d3a9bd30..19ad16f32 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -1,12 +1,11 @@ import numpy as np -from rocketpy.mathutils.function import Function from rocketpy.prints.aero_surface_prints import _RailButtonsPrints -from .aero_surface import AeroSurface +from .generic_surface import GenericSurface -class RailButtons(AeroSurface): +class RailButtons(GenericSurface): """Class that defines a rail button pair or group. Attributes @@ -53,14 +52,24 @@ def __init__( If not provided, it will be calculated when the RailButtons object is added to a Rocket object. """ - super().__init__(name, None, None) self.buttons_distance = buttons_distance self.angular_position = angular_position self.button_height = button_height - self.name = name self.rocket_radius = rocket_radius - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() + + # Rail buttons produce no aerodynamic force; they are modeled as a + # generic surface with all-zero coefficients. The reference area/length + # are placeholders (never used, since rail buttons are not part of the + # rocket's aerodynamic_surfaces) computed from the rocket radius when + # available. + reference_radius = rocket_radius or 1.0 + super().__init__( + reference_area=np.pi * reference_radius**2, + reference_length=2 * reference_radius, + coefficients={}, + center_of_pressure=(0, 0, 0), + name=name, + ) self.prints = _RailButtonsPrints(self) @@ -68,47 +77,6 @@ def __init__( def angular_position_rad(self): return np.radians(self.angular_position) - def evaluate_center_of_pressure(self): - """Evaluates the center of pressure of the rail buttons. Rail buttons - do not contribute to the center of pressure of the rocket. - - Returns - ------- - None - """ - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - self.cp = (self.cpx, self.cpy, self.cpz) - - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the rail buttons. Rail - buttons do not contribute to the lift coefficient of the rocket. - - Returns - ------- - None - """ - self.clalpha = Function( - lambda mach: 0, - "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: 0, - ["Alpha (rad)", "Mach"], - "Cl", - ) - - def evaluate_geometrical_parameters(self): - """Evaluates the geometrical parameters of the rail buttons. Rail - buttons do not contribute to the geometrical parameters of the rocket. - - Returns - ------- - None - """ - def to_dict(self, **kwargs): # pylint: disable=unused-argument return { "buttons_distance": self.buttons_distance, diff --git a/rocketpy/rocket/aero_surface/tail.py b/rocketpy/rocket/aero_surface/tail.py index 3e738f99c..0066bcf86 100644 --- a/rocketpy/rocket/aero_surface/tail.py +++ b/rocketpy/rocket/aero_surface/tail.py @@ -4,10 +4,10 @@ from rocketpy.plots.aero_surface_plots import _TailPlots from rocketpy.prints.aero_surface_prints import _TailPrints -from .aero_surface import AeroSurface +from ._barrowman_surface import _BarrowmanSurface -class Tail(AeroSurface): +class Tail(_BarrowmanSurface): """Class that defines a tail. Currently only accepts conical tails. Note @@ -76,7 +76,9 @@ def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail" ------- None """ - super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + self.name = name + self.reference_area = np.pi * rocket_radius**2 + self.reference_length = 2 * rocket_radius self._top_radius = top_radius self._bottom_radius = bottom_radius @@ -87,6 +89,16 @@ def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail" self.evaluate_lift_coefficient() self.evaluate_center_of_pressure() + # Translate the Barrowman geometry into the linear generic-surface + # coefficient model and build the shared compute path. + super().__init__( + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=name, + ) + self.plots = _TailPlots(self) self.prints = _TailPrints(self) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index c16c799ce..2092e89e6 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -133,9 +133,12 @@ class Rocket: Collection of air brakes of the rocket. Rocket._controllers : list Collection of controllers of the rocket. - Rocket.cp_position : Function - Function of Mach number expressing the rocket's center of pressure - position relative to user defined rocket reference system. + Rocket.aerodynamic_center : Function + Function of Mach number expressing the rocket's aerodynamic center + (the linearized, small-incidence center of pressure) position relative + to the user defined rocket reference system. The nonlinear center of + pressure at a finite angle of attack is :meth:`Rocket.center_of_pressure`. + ``Rocket.cp_position`` is a deprecated alias for this attribute. See :doc:`Positions and Coordinate Systems ` for more information. Rocket.stability_margin : Function @@ -345,10 +348,10 @@ def __init__( # pylint: disable=too-many-statements self.surfaces_cp_to_cdm = {} self.rail_buttons = Components() - self.cp_position = Function( + self.aerodynamic_center = Function( lambda mach: 0, inputs="Mach Number", - outputs="Center of Pressure Position (m)", + outputs="Aerodynamic Center Position (m)", ) self.total_lift_coeff_der = Function( lambda mach: 0, @@ -363,6 +366,27 @@ def __init__( # pylint: disable=too-many-statements inputs=["Mach", "Time (s)"], outputs="Stability Margin (c)", ) + # Yaw-plane counterparts. The pitch-plane attributes above remain the + # primary (default) margin; these expose the yaw plane for + # non-axisymmetric rockets (see ``evaluate_center_of_pressure``). + self.aerodynamic_center_yaw = Function( + lambda mach: 0, + inputs="Mach Number", + outputs="Aerodynamic Center Position - Yaw (m)", + ) + self.total_side_coeff_der = Function( + lambda mach: 0, + inputs="Mach Number", + outputs="Total Side Coefficient Derivative", + ) + self.static_margin_yaw = Function( + lambda time: 0, inputs="Time (s)", outputs="Static Margin - Yaw (c)" + ) + self.stability_margin_yaw = Function( + lambda mach, time: 0, + inputs=["Mach", "Time (s)"], + outputs="Stability Margin - Yaw (c)", + ) # Define aerodynamic drag coefficients # Coefficients used during flight simulation @@ -610,42 +634,491 @@ def evaluate_thrust_to_weight(self): self.thrust_to_weight.set_title("Thrust to Weight ratio") def evaluate_center_of_pressure(self): - """Evaluates rocket center of pressure position relative to user defined - rocket reference system. It can be called as many times as needed, as it - will update the center of pressure function every time it is called. The - code will iterate through all aerodynamic surfaces and consider each of - their center of pressure position and derivative of the coefficient of - lift as a function of Mach number. + """Evaluates the rocket's **aerodynamic center** as a function of Mach + number, relative to the user-defined rocket reference system. + + The aerodynamic center is the linearized (small-incidence, + :math:`\\alpha=\\beta=0`) center of pressure: the normal-force-slope- + weighted average of every aerodynamic surface's location. It is the + well-conditioned reference used by the static and stability margins and + is distinct from :meth:`center_of_pressure`, which is the *nonlinear* + center of pressure at a finite angle of attack/sideslip. + + It is computed independently for the **pitch** plane + (``aerodynamic_center``, from the normal-force/pitch-moment slopes) and + the **yaw** plane (``aerodynamic_center_yaw``, from the + side-force/yaw-moment slopes). For an axisymmetric rocket the two + coincide; when they differ (a non-axisymmetric configuration, only + expressible through ``GenericSurface``), a warning is raised because the + scalar ``static_margin``/``stability_margin`` attributes describe the + pitch plane only. Returns ------- - self.cp_position : Function - Function of Mach number expressing the rocket's center of pressure - position relative to user defined rocket reference system. - See :doc:`Positions and Coordinate Systems ` - for more information. + self.aerodynamic_center : Function + Function of Mach number expressing the rocket's pitch-plane + aerodynamic center position relative to the user-defined rocket + reference system. See :doc:`Positions and Coordinate Systems + ` for more information. """ - # Re-Initialize total lift coefficient derivative and center of pressure position + # Re-Initialize total force coefficient derivatives and AC positions self.total_lift_coeff_der.set_source(lambda mach: 0) - self.cp_position.set_source(lambda mach: 0) + self.aerodynamic_center.set_source(lambda mach: 0) + self.total_side_coeff_der.set_source(lambda mach: 0) + self.aerodynamic_center_yaw.set_source(lambda mach: 0) - # Calculate total lift coefficient derivative and center of pressure + # Calculate total force coefficient derivatives and aerodynamic center if len(self.aerodynamic_surfaces) > 0: for aero_surface, position in self.aerodynamic_surfaces: - if isinstance(aero_surface, GenericSurface): - continue - # ref_factor corrects lift for different reference areas - ref_factor = (aero_surface.rocket_radius / self.radius) ** 2 - self.total_lift_coeff_der += ref_factor * aero_surface.clalpha - self.cp_position += ( - ref_factor - * aero_surface.clalpha - * (position.z - self._csys * aero_surface.cpz) + lift_coeff_der = aero_surface.lift_coefficient_derivative + cp_z = aero_surface.center_of_pressure_z + # ref_factor corrects force for different reference areas + ref_factor = aero_surface.reference_area / self.area + self.total_lift_coeff_der += ref_factor * lift_coeff_der + self.aerodynamic_center += ( + ref_factor * lift_coeff_der * (position.z - self._csys * cp_z) ) - # Avoid errors when only generic surfaces are added + + # Yaw plane. + side_coeff_der = aero_surface.side_coefficient_derivative + cp_z_yaw = aero_surface.center_of_pressure_z_yaw + self.total_side_coeff_der += ref_factor * side_coeff_der + self.aerodynamic_center_yaw += ( + ref_factor * side_coeff_der * (position.z - self._csys * cp_z_yaw) + ) + # Avoid errors when only zero-lift surfaces are added if self.total_lift_coeff_der.get_value(0) != 0: - self.cp_position /= self.total_lift_coeff_der - return self.cp_position + self.aerodynamic_center /= self.total_lift_coeff_der + if self.total_side_coeff_der.get_value(0) != 0: + self.aerodynamic_center_yaw /= self.total_side_coeff_der + + self._warn_if_asymmetric_cp() + return self.aerodynamic_center + + def _cp_plane_max_difference(self): + """Largest pitch- vs yaw-plane aerodynamic center difference, in meters, + over a few sample Mach numbers.""" + sample_machs = (0.0, 0.5, 1.0) + return max( + abs( + self.aerodynamic_center.get_value_opt(mach) + - self.aerodynamic_center_yaw.get_value_opt(mach) + ) + for mach in sample_machs + ) + + @property + def is_axisymmetric(self): + """``True`` when the rocket's pitch- and yaw-plane aerodynamic centers + coincide (to caliber-scale tolerance). When ``False`` the rocket is not + axisymmetric: ``aerodynamic_center``, ``static_margin`` and + ``stability_margin`` describe the PITCH plane only and differ from their + ``*_yaw`` counterparts (``aerodynamic_center_yaw``, ``static_margin_yaw``, + ``stability_margin_yaw``).""" + # Tolerance relative to the rocket diameter (caliber-scale). + return self._cp_plane_max_difference() <= 1e-6 * (2 * self.radius) + + @property + def cp_position(self): + """Deprecated alias for :attr:`aerodynamic_center` (the linearized, + Mach-dependent center of pressure / aerodynamic center).""" + warnings.warn( + "'cp_position' is deprecated and will be removed in a future " + "release; use 'aerodynamic_center' (the linearized center of " + "pressure) instead. For the nonlinear center of pressure at a given " + "angle of attack use 'center_of_pressure(alpha, beta, mach)'.", + DeprecationWarning, + stacklevel=2, + ) + return self.aerodynamic_center + + @property + def cp_position_yaw(self): + """Deprecated alias for :attr:`aerodynamic_center_yaw`.""" + warnings.warn( + "'cp_position_yaw' is deprecated and will be removed in a future " + "release; use 'aerodynamic_center_yaw' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.aerodynamic_center_yaw + + def _warn_if_asymmetric_cp(self): + """Warn when the pitch- and yaw-plane aerodynamic centers disagree, i.e. + the rocket is not axisymmetric. The ``static_margin``/ + ``stability_margin`` attributes then describe the pitch plane only; the + yaw-plane counterparts are ``*_yaw``.""" + if not self.is_axisymmetric: + max_diff = self._cp_plane_max_difference() + warnings.warn( + "Pitch- and yaw-plane aerodynamic centers differ " + f"(max difference ~{max_diff:.4g} m): the rocket is not " + "axisymmetric. 'aerodynamic_center', 'static_margin' and " + "'stability_margin' describe the PITCH plane; use " + "'aerodynamic_center_yaw', 'static_margin_yaw' and " + "'stability_margin_yaw' for the yaw plane.", + stacklevel=2, + ) + + def _aerodynamic_center_limit(self, alpha, beta, mach): + """Small-incidence limit of :meth:`center_of_pressure`: the linearized + aerodynamic center, blended between the pitch and yaw planes by the + squared normal-force contribution of each, so the nonlinear center of + pressure stays continuous in the ``(alpha, beta)`` direction as the + incidence goes to zero (pure pitch -> pitch AC, pure sideslip -> yaw AC). + """ + weight_pitch = (self.total_lift_coeff_der.get_value_opt(mach) * alpha) ** 2 + weight_yaw = (self.total_side_coeff_der.get_value_opt(mach) * beta) ** 2 + pitch = self.aerodynamic_center.get_value_opt(mach) + if weight_pitch + weight_yaw == 0: + return pitch + yaw = self.aerodynamic_center_yaw.get_value_opt(mach) + return (pitch * weight_pitch + yaw * weight_yaw) / (weight_pitch + weight_yaw) + + def center_of_pressure(self, alpha, beta, mach, reynolds=0.0): + """Nonlinear center of pressure axial position, as a function of the + aerodynamic state. + + Unlike :attr:`aerodynamic_center` (the linearized center of pressure, a + function of Mach alone, valid only near zero incidence), this aggregates + the *actual* force and moment of every aerodynamic surface at the + requested angle of attack ``alpha``, sideslip ``beta`` and Mach number, + then locates the axial point about which the resultant transverse + aerodynamic force produces no moment: + ``z_cp = (M2*R1 - M1*R2) / (R1**2 + R2**2)`` (about the center of dry + mass, from ``M = r x F``). + + Because the resultant force is evaluated at the actual *combined* + incidence, a single axial location captures both the pitch and the yaw + plane -- the ``aerodynamic_center``/``aerodynamic_center_yaw`` split is + only needed for the linearized slopes, which must pick a perturbation + axis. As ``alpha, beta -> 0`` the normal force vanishes and the location + becomes a ``0/0`` limit; there the linearized aerodynamic center + (:attr:`aerodynamic_center`) is returned, which the nonlinear value + converges to. + + Parameters + ---------- + alpha : float + Angle of attack, in radians (pitch plane, body aerodynamic frame). + beta : float + Sideslip angle, in radians (yaw plane, body aerodynamic frame). + mach : float + Free-stream Mach number. + reynolds : float, optional + Rocket-level Reynolds number (based on the rocket diameter). Each + surface's Reynolds number is scaled to its own reference length. + Defaults to ``0`` (vanishing-Reynolds limit, matching the linearized + ``aerodynamic_center`` convention). + + Returns + ------- + float + Center of pressure position along the rocket axis, in the rocket + coordinate system (m). See :doc:`Positions and Coordinate Systems + `. + """ + # Below ~1 deg total incidence the normal force is too small for the + # moment/normal-force ratio to be well conditioned (a 0/0 limit at the + # origin, finite-precision noise just above it). Return the linearized + # aerodynamic center, which the nonlinear value converges to, so the + # result is continuous and never spikes as the rocket oscillates through + # zero incidence. + if alpha**2 + beta**2 < math.radians(1.0) ** 2 or ( + len(self.aerodynamic_surfaces) == 0 + ): + return self._aerodynamic_center_limit(alpha, beta, mach) + + total_x, total_y, _, moment_x, moment_y, _, _ = ( + self._aerodynamic_forces_and_moments(alpha, beta, mach, reynolds) + ) + + normal_force_sq = total_x**2 + total_y**2 + if normal_force_sq == 0: + return self._aerodynamic_center_limit(alpha, beta, mach) + # Axial offset (body frame) from the center of dry mass to the line of + # action of the resultant transverse force. + cp_offset = (moment_y * total_x - moment_x * total_y) / normal_force_sq + return self.center_of_dry_mass_position + self._csys * cp_offset + + def _aerodynamic_forces_and_moments(self, alpha, beta, mach, reynolds=0.0): + """Total body-frame aerodynamic force ``(R1, R2, R3)`` and moment + ``(M1, M2, M3)`` about the center of dry mass, summed over every + aerodynamic surface at a static state (zero rates), plus the + ``stream_speed`` used. + + Computed at unit air density, so the forces equal the dimensionless + coefficients times ``0.5 * stream_speed**2 * reference_area``; dynamic + pressure therefore cancels from any coefficient or center-of-pressure + ratio. The viscosity is chosen so each surface's Reynolds number (built + on its own reference length) is consistent with the requested + rocket-level Reynolds number (built on the diameter): + ``Re_surface = reynolds * reference_length / (2 * radius)``; a + non-positive Reynolds collapses to the vanishing-Reynolds limit. + """ + # Body-frame stream velocity reproducing (alpha, beta). + # ``compute_forces_and_moments`` negates it internally and recovers + # ``alpha = atan2(sv_y, sv_z)`` and ``beta = atan2(sv_x, sv_z)``. + stream_velocity = Vector([-math.tan(beta), -math.tan(alpha), -1.0]) + stream_speed = abs(stream_velocity) + omega = Vector([0, 0, 0]) + density = Function(1.0) + if reynolds > 0: + dynamic_viscosity = Function(stream_speed * 2 * self.radius / reynolds) + else: + dynamic_viscosity = Function(1e30) + + totals = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + for surface, _ in self.aerodynamic_surfaces: + cp = self.surfaces_cp_to_cdm[surface] + forces = surface.compute_forces_and_moments( + stream_velocity, stream_speed, mach, 1.0, cp, omega, + density, dynamic_viscosity, 0.0, + ) + totals = [acc + value for acc, value in zip(totals, forces)] + return (*totals, stream_speed) + + def aerodynamic_coefficients(self, alpha, beta, mach, reynolds=0.0): + """Total rocket aerodynamic coefficients at a given state, referenced to + the rocket cross-section area and diameter and taken about the center of + dry mass. + + Parameters + ---------- + alpha, beta : float + Angle of attack and sideslip, in radians. + mach : float + Free-stream Mach number. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + + Returns + ------- + dict + ``{"normal_force": C_N, "pitch_moment": C_m}`` -- the total + normal-force and pitch-moment (about the center of dry mass) + coefficient magnitudes. + + Notes + ----- + The rocket's axial (drag) coefficient is **not** included: the geometric + (Barrowman) surfaces carry no drag coefficient, the rocket drag being + supplied separately by ``power_off_drag``/``power_on_drag``. See + :meth:`Rocket.plots.drag_curves`. + """ + r1, r2, _, m1, m2, _, stream_speed = self._aerodynamic_forces_and_moments( + alpha, beta, mach, reynolds + ) + dynamic_pressure_area = 0.5 * stream_speed**2 * self.area + if dynamic_pressure_area == 0: + return {"normal_force": 0.0, "pitch_moment": 0.0} + reference_length = 2 * self.radius + return { + "normal_force": (r1**2 + r2**2) ** 0.5 / dynamic_pressure_area, + "pitch_moment": (m1**2 + m2**2) ** 0.5 + / (dynamic_pressure_area * reference_length), + } + + def aerodynamic_coefficients_full(self, alpha, beta, mach, reynolds=0.0): + """All six signed rocket-level aerodynamic coefficients at a state. + + Aggregates every aerodynamic surface into the vehicle's force and moment + coefficients, referenced to the rocket cross-section area and diameter + and taken about the center of dry mass, in the body aerodynamic frame: + + - ``cL`` (lift), ``cQ`` (side force), ``cD`` (drag); + - ``cm`` (pitch), ``cn`` (yaw), ``cl`` (roll). + + Unlike :meth:`aerodynamic_coefficients` (which returns unsigned + normal-force and pitch-moment magnitudes) these are signed and complete. + The drag coefficient ``cD`` is taken from the vehicle drag curve + (``power_off_drag``), since the geometric surfaces carry no drag + coefficient; this unifies the per-surface lift/moment model with the + separately supplied drag curve into a single coefficient set. + + Parameters + ---------- + alpha, beta : float + Angle of attack and sideslip, in radians. + mach : float + Free-stream Mach number. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + + Returns + ------- + dict + ``{"cL", "cQ", "cD", "cm", "cn", "cl"}``. + """ + r1, r2, r3, m1, m2, m3, stream_speed = self._aerodynamic_forces_and_moments( + alpha, beta, mach, reynolds + ) + dynamic_pressure_area = 0.5 * stream_speed**2 * self.area + if dynamic_pressure_area == 0: + return {c: 0.0 for c in ("cL", "cQ", "cD", "cm", "cn", "cl")} + reference_length = 2 * self.radius + dynamic_pressure_area_length = dynamic_pressure_area * reference_length + # Body-frame force/moment components map to the aerodynamic-frame + # coefficients (see GenericSurface.compute_forces_and_moments, which + # builds the body force from Vector([side, -lift, -drag])). + return { + "cL": -r2 / dynamic_pressure_area, + "cQ": r1 / dynamic_pressure_area, + "cD": self.power_off_drag_by_mach.get_value_opt(mach), + "cm": m1 / dynamic_pressure_area_length, + "cn": m2 / dynamic_pressure_area_length, + "cl": m3 / dynamic_pressure_area_length, + } + + def center_of_pressure_over_alpha(self, mach=0.0, beta=0.0, reynolds=0.0): + """Center of pressure position as a Function of angle of attack. + + Convenience wrapper around :meth:`center_of_pressure` that fixes the + Mach number, sideslip and Reynolds number and exposes the center of + pressure travel with angle of attack as a plottable :class:`Function`. + + Parameters + ---------- + mach : float, optional + Free-stream Mach number. Default 0. + beta : float, optional + Sideslip angle, in radians. Default 0. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + + Returns + ------- + Function + Center of pressure position (m) versus angle of attack (rad). + """ + return Function( + lambda alpha: self.center_of_pressure(alpha, beta, mach, reynolds), + inputs="Angle of Attack (rad)", + outputs="Center of Pressure Position (m)", + title="Center of Pressure vs Angle of Attack", + ) + + def stability_margin_over_alpha( + self, mach=0.0, beta=0.0, reynolds=0.0, time=0.0 + ): + """Stability margin in calibers as a Function of angle of attack. + + The center-of-gravity-to-center-of-pressure distance divided by the + rocket diameter, using the nonlinear :meth:`center_of_pressure` so the + margin reflects how the center of pressure moves with incidence. This is + the angle-of-attack analogue of :attr:`static_margin` (which is the + ``alpha = 0`` value as a function of time). + + Parameters + ---------- + mach : float, optional + Free-stream Mach number. Default 0. + beta : float, optional + Sideslip angle, in radians. Default 0. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + time : float, optional + Time at which the center of mass is evaluated, in seconds. Default 0 + (the fully loaded condition). + + Returns + ------- + Function + Stability margin (calibers) versus angle of attack (rad). + """ + center_of_pressure = self.center_of_pressure_over_alpha(mach, beta, reynolds) + center_of_mass = self.center_of_mass.get_value_opt(time) + diameter = 2 * self.radius + return Function( + lambda alpha: ( + (center_of_mass - center_of_pressure.get_value_opt(alpha)) + / diameter + * self._csys + ), + inputs="Angle of Attack (rad)", + outputs="Stability Margin (c)", + title="Stability Margin vs Angle of Attack", + ) + + def center_of_pressure_over_beta(self, mach=0.0, alpha=0.0, reynolds=0.0): + """Center of pressure position as a Function of sideslip angle. + + Yaw-plane companion to :meth:`center_of_pressure_over_alpha`: fixes the + Mach number, angle of attack and Reynolds number and exposes the center + of pressure travel with sideslip as a plottable :class:`Function`. + + Parameters + ---------- + mach : float, optional + Free-stream Mach number. Default 0. + alpha : float, optional + Angle of attack, in radians. Default 0. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + + Returns + ------- + Function + Center of pressure position (m) versus sideslip angle (rad). + """ + def _cp(beta): + # At the exact origin the CP is a 0/0 limit; the general + # center_of_pressure resolves it to the PITCH-plane aerodynamic + # center (consistent with static_margin). For a pure-sideslip sweep + # the correct limit is instead the YAW-plane aerodynamic center, + # which the nonlinear value converges to as beta grows -- use it at + # beta = 0 so the sweep stays continuous. + if alpha == 0.0 and beta == 0.0: + return self.aerodynamic_center_yaw.get_value_opt(mach) + return self.center_of_pressure(alpha, beta, mach, reynolds) + + return Function( + _cp, + inputs="Sideslip Angle (rad)", + outputs="Center of Pressure Position (m)", + title="Center of Pressure vs Sideslip Angle", + ) + + def stability_margin_over_beta( + self, mach=0.0, alpha=0.0, reynolds=0.0, time=0.0 + ): + """Stability margin in calibers as a Function of sideslip angle. + + Yaw-plane companion to :meth:`stability_margin_over_alpha`: the + center-of-gravity-to-center-of-pressure distance divided by the rocket + diameter, using the nonlinear :meth:`center_of_pressure` so the margin + reflects how the center of pressure moves with sideslip. + + Parameters + ---------- + mach : float, optional + Free-stream Mach number. Default 0. + alpha : float, optional + Angle of attack, in radians. Default 0. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + time : float, optional + Time at which the center of mass is evaluated, in seconds. Default 0 + (the fully loaded condition). + + Returns + ------- + Function + Stability margin (calibers) versus sideslip angle (rad). + """ + center_of_pressure = self.center_of_pressure_over_beta(mach, alpha, reynolds) + center_of_mass = self.center_of_mass.get_value_opt(time) + diameter = 2 * self.radius + return Function( + lambda beta: ( + (center_of_mass - center_of_pressure.get_value_opt(beta)) + / diameter + * self._csys + ), + inputs="Sideslip Angle (rad)", + outputs="Stability Margin (c)", + title="Stability Margin vs Sideslip Angle", + ) def evaluate_surfaces_cp_to_cdm(self): """Calculates the relative position of each aerodynamic surface center @@ -674,11 +1147,17 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): (position.z - self.center_of_dry_mass_position) * self._csys, ] ) - # position of the center of pressure in body frame + # position of the force application point in body frame. Surfaces that + # carry their center-of-pressure offset in the moment coefficients + # (Barrowman surfaces) apply the force at the origin; surfaces that + # transport the moment geometrically use their center of pressure. + application_point = getattr( + surface, + "force_application_point", + Vector([surface.cpx, surface.cpy, surface.cpz]), + ) pos = ( - surface._rotation_surface_to_body - @ Vector([surface.cpx, surface.cpy, surface.cpz]) - + pos_origin + surface._rotation_surface_to_body @ application_point + pos_origin ) # TODO: this should be recomputed whenever cant angle changes for fin self.surfaces_cp_to_cdm[surface] = pos @@ -699,7 +1178,20 @@ def evaluate_stability_margin(self): ( ( self.center_of_mass.get_value_opt(time) - - self.cp_position.get_value_opt(mach) + - self.aerodynamic_center.get_value_opt(mach) + ) + / (2 * self.radius) + ) + * self._csys + ) + ) + # Yaw-plane stability margin (equal to the pitch plane when axisymmetric) + self.stability_margin_yaw.set_source( + lambda mach, time: ( + ( + ( + self.center_of_mass.get_value_opt(time) + - self.aerodynamic_center_yaw.get_value_opt(mach) ) / (2 * self.radius) ) @@ -723,7 +1215,7 @@ def evaluate_static_margin(self): lambda time: ( ( self.center_of_mass.get_value_opt(time) - - self.cp_position.get_value_opt(0) + - self.aerodynamic_center.get_value_opt(0) ) / (2 * self.radius) ) @@ -736,6 +1228,24 @@ def evaluate_static_margin(self): self.static_margin.set_discrete( lower=0, upper=self.motor.burn_out_time, samples=200 ) + + # Yaw-plane static margin (equal to the pitch plane when axisymmetric) + self.static_margin_yaw.set_source( + lambda time: ( + ( + self.center_of_mass.get_value_opt(time) + - self.aerodynamic_center_yaw.get_value_opt(0) + ) + / (2 * self.radius) + ) + ) + self.static_margin_yaw *= self._csys + self.static_margin_yaw.set_inputs("Time (s)") + self.static_margin_yaw.set_outputs("Static Margin - Yaw (c)") + self.static_margin_yaw.set_title("Static Margin - Yaw") + self.static_margin_yaw.set_discrete( + lower=0, upper=self.motor.burn_out_time, samples=200 + ) return self.static_margin def evaluate_dry_inertias(self): @@ -1152,6 +1662,65 @@ def add_surfaces(self, surfaces, positions): self.evaluate_stability_margin() self.evaluate_static_margin() + def add_vehicle_aerodynamic_surface( + self, coefficients, reference_position=None, name="Vehicle Aerodynamics" + ): + """Define the whole vehicle from a supplied set of aerodynamic + coefficients (a "rocket-as-:class:`GenericSurface`" model). + + Instead of (or in addition to) modeling each surface, this lets a user + fly the 6-DOF directly from a full-vehicle coefficient set, e.g. exported + from CFD, a wind tunnel, or OpenRocket. The coefficients are wrapped in a + single :class:`GenericSurface` referenced to the rocket cross-section + area and diameter and added through the standard aerodynamic-surface + path, so the equations of motion sum it like any other surface. + + Because it is just another aerodynamic surface, a vehicle coefficient set + can be **mixed** with modeled add-on surfaces (e.g. a measured body plus + modeled canards): they simply add. + + Parameters + ---------- + coefficients : dict + Aerodynamic coefficients ``cL, cQ, cD, cm, cn, cl`` (omitted ones + default to 0), each a number, callable, :class:`Function` or CSV + path of ``(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, + roll_rate)`` -- the same input forms accepted by + :class:`GenericSurface`. + reference_position : int, float, optional + Axial station (in the user coordinate system) about which the + supplied moment coefficients are defined and where the resultant + force is applied. Defaults to the center of dry mass position. The + supplied moment coefficients are taken about this fixed station. + name : str, optional + Name of the surface. Default ``"Vehicle Aerodynamics"``. + + Returns + ------- + GenericSurface + The created vehicle aerodynamic surface (also added to the rocket). + + Notes + ----- + A single vehicle coefficient set necessarily drops per-surface locals + (the ``omega x r`` velocity at each surface, per-surface Reynolds, rail + buttons and individual-fin roll). For controllable vehicle coefficients + (deflection axes), build a + :class:`ControllableGenericSurface` and add it with + :meth:`add_surfaces` / ``add_controllable_surface`` instead. + """ + if reference_position is None: + reference_position = self.center_of_dry_mass_position + + surface = GenericSurface( + reference_area=self.area, + reference_length=2 * self.radius, + coefficients=coefficients, + name=name, + ) + self.add_surfaces(surface, reference_position) + return surface + def _add_controllers(self, controllers): """Adds a controller to the rocket. @@ -1987,6 +2556,73 @@ def controller_wrapper(**kwargs): else: return air_brakes + def add_controllable_surface( + self, + surface, + position, + controller_function, + sampling_rate, + controlled_object_name="controllable_surface", + context=None, + name="Controller", + controller_needs=None, + return_controller=False, + ): + """Add a controllable aerodynamic surface and the controller that drives + its deflection during flight. + + The surface is added like any other aerodynamic surface (so it flows + through the standard per-surface force/moment computation), and a + controller is registered to mutate the surface's control variables each + sample. The controller function should set the surface's deflection via + ``surface.set_control(name, value)``. + + Parameters + ---------- + surface : ControllableGenericSurface + The controllable surface to add. + position : int, float, tuple, list, Vector + Position of the surface, in the same convention as + :meth:`add_surfaces`. + controller_function : callable + Control logic, ``controller_function(**kwargs) -> dict or None``. + See :class:`rocketpy.control.controller._Controller` for the + available ``kwargs``. The controlled surface is exposed under + ``controlled_object_name``. + sampling_rate : float + Controller sampling rate in hertz. + controlled_object_name : str, optional + Friendly name under which the surface is exposed in the controller + ``kwargs``. Default ``"controllable_surface"``. + context : dict, optional + Initial persistent controller context. Default ``None``. + name : str, optional + Controller name. Default ``"Controller"``. + controller_needs : list or frozenset of str or None, optional + Expensive simulation values the controller accesses. + return_controller : bool, optional + If True, also return the created controller. Default False. + + Returns + ------- + ControllableGenericSurface or tuple + The surface, or ``(surface, controller)`` if ``return_controller``. + """ + self.add_surfaces(surface, position) + controller = _Controller( + controller_function=controller_function, + controlled_objects=surface, + controlled_objects_name=controlled_object_name, + sampling_rate=sampling_rate, + context=context if context is not None else {}, + name=name, + controller_needs=controller_needs, + ) + self._add_controllers(controller) + if return_controller: + return surface, controller + return surface + def set_rail_buttons( self, upper_button_position, @@ -2230,7 +2866,7 @@ def to_dict(self, **kwargs): if kwargs.get("include_outputs", False): thrust_to_weight = self.thrust_to_weight - cp_position = self.cp_position + aerodynamic_center = self.aerodynamic_center stability_margin = self.stability_margin center_of_mass = self.center_of_mass motor_center_of_mass_position = self.motor_center_of_mass_position @@ -2243,7 +2879,9 @@ def to_dict(self, **kwargs): thrust_to_weight = thrust_to_weight.set_discrete_based_on_model( self.motor.thrust, mutate_self=False ) - cp_position = cp_position.set_discrete(0, 4, 25, mutate_self=False) + aerodynamic_center = aerodynamic_center.set_discrete( + 0, 4, 25, mutate_self=False + ) stability_margin = stability_margin.set_discrete( (0, self.motor.burn_time[0]), (2, self.motor.burn_time[1]), @@ -2293,7 +2931,7 @@ def to_dict(self, **kwargs): rocket_dict["cp_eccentricity_y"] = self.cp_eccentricity_y rocket_dict["thrust_eccentricity_x"] = self.thrust_eccentricity_x rocket_dict["thrust_eccentricity_y"] = self.thrust_eccentricity_y - rocket_dict["cp_position"] = cp_position + rocket_dict["aerodynamic_center"] = aerodynamic_center rocket_dict["stability_margin"] = stability_margin rocket_dict["static_margin"] = self.static_margin rocket_dict["nozzle_position"] = self.nozzle_position diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index baff48a38..11eb76477 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -2305,24 +2305,289 @@ def static_margin(self): @funcify_method("Time (s)", "Stability Margin (c)", "linear", "zero") def stability_margin(self): - """Stability margin of the rocket along the flight, it considers the - variation of the center of pressure position according to the mach - number, as well as the variation of the center of gravity position - according to the propellant mass evolution. + """Linear stability margin along the flight, in calibers. - Parameters - ---------- - None + This is the classical (aerodynamic-center) margin: it evaluates the + rocket's linearized stability margin + (:meth:`Rocket.stability_margin`) at the realized flight Mach and time at + each instant, capturing the Mach variation of the aerodynamic center + together with the center-of-mass shift as propellant burns. It is + well-conditioned and never spikes. For the nonlinear margin that follows + the center of pressure at the actual angle of attack/sideslip, see + :meth:`realized_stability_margin`. Returns ------- stability : rocketpy.Function - Stability margin as a rocketpy.Function of time. The stability margin - is defined as the distance between the center of pressure and the - center of gravity, divided by the rocket diameter. + Stability margin in calibers as a function of time. A positive + margin (aerodynamic center behind the center of mass) is the classic + passive-stability condition. """ return [(t, self.rocket.stability_margin(m, t)) for t, m in self.mach_number] + @funcify_method("Time (s)", "Stability Margin - Yaw (c)", "linear", "zero") + def stability_margin_yaw(self): + """Linear yaw-plane stability margin along the flight, in calibers. + + Yaw-plane counterpart of :meth:`stability_margin`, using the rocket's + yaw-plane aerodynamic center (:meth:`Rocket.stability_margin_yaw`). + Equals :meth:`stability_margin` for an axisymmetric rocket; for a + non-axisymmetric rocket (e.g. single-plane canards) it differs, since the + pitch and yaw aerodynamic centers no longer coincide. + + Returns + ------- + stability : rocketpy.Function + Yaw-plane stability margin in calibers as a function of time. + """ + return [ + (t, self.rocket.stability_margin_yaw(m, t)) for t, m in self.mach_number + ] + + @funcify_method("Time (s)", "Realized Stability Margin (c)", "linear", "zero") + def realized_stability_margin(self): + """Nonlinear (realized) stability margin along the flight, in calibers. + + Co-equal companion to :meth:`stability_margin`: instead of the + aerodynamic center it uses the rocket's *nonlinear* center of pressure + (:meth:`Rocket.center_of_pressure`) at the realized flight state -- the + actual angle of attack, sideslip, Mach and Reynolds at each time step -- + so it reveals how the center of pressure travels with incidence (and, + for non-axisymmetric rockets, combines the pitch and yaw planes at the + actual combined incidence). + + For a non-axisymmetric rocket the margin is **direction-dependent** (the + pitch and yaw planes differ), and during most of the flight the rocket + flies at near-zero incidence, where the *direction* of the residual + incidence vector is numerical noise (slight coning). Reporting the + directional center of pressure there would make the margin swing between + the pitch- and yaw-plane values. To avoid that, the realized value is + blended into the linear margin by how much real incidence there is: at + negligible incidence the result is the linear :meth:`stability_margin`, + and only a genuine disturbance (a few degrees of incidence) reveals the + nonlinear travel. The value also falls back to the linear margin where + the dynamic pressure is negligible (rail, rest, apogee). + + Returns + ------- + stability : rocketpy.Function + Realized stability margin in calibers as a function of time. + """ + csys = self.rocket._csys + diameter = 2 * self.rocket.radius + time = self.time + + alpha = np.array( + [self.partial_angle_of_attack.get_value_opt(t) for t in time] + ) + beta = np.array([self.angle_of_sideslip.get_value_opt(t) for t in time]) + + center_of_pressure = np.array( + [ + self.rocket.center_of_pressure( + np.deg2rad(a), + np.deg2rad(b), + self.mach_number.get_value_opt(t), + self.reynolds_number.get_value_opt(t), + ) + for a, b, t in zip(alpha, beta, time) + ] + ) + center_of_mass = np.array( + [self.rocket.center_of_mass.get_value_opt(t) for t in time] + ) + margin_realized = (center_of_mass - center_of_pressure) / diameter * csys + + margin_model = np.array( + [ + self.rocket.stability_margin.get_value_opt( + self.mach_number.get_value_opt(t), t + ) + for t in time + ] + ) + + # Weight the (direction-dependent) realized value by how much real + # incidence there is, with a smoothstep ramp up to ~2 deg, so the + # near-zero-incidence direction noise collapses to the linear margin. + incidence = np.hypot(alpha, beta) + weight = np.clip(incidence / 2.0, 0.0, 1.0) + weight = weight**2 * (3.0 - 2.0 * weight) + margin_blended = (1.0 - weight) * margin_model + weight * margin_realized + + # Fall back fully to the linear margin where the rocket is barely moving + # (dynamic pressure below 1% of its flight-wide peak: rail, rest, apogee). + dynamic_pressure = np.array( + [self.dynamic_pressure.get_value_opt(t) for t in time] + ) + meaningful = dynamic_pressure > 0.01 * dynamic_pressure.max() + margin = np.where(meaningful, margin_blended, margin_model) + + return np.column_stack((time, margin)) + + # Dynamic stability + def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): + """Lateral moment of inertia about the instantaneous center of mass, as + an array over ``self.time``. Uses the reduced-mass formulation of the + equations of motion: ``I_L = I_dry + I_motor(t) + mu(t) b^2`` with + ``mu`` the dry/propellant reduced mass and ``b`` the (initial) + dry-mass-to-propellant distance.""" + dry_mass = self.rocket.dry_mass + b = ( + -( + self.rocket.center_of_propellant_position.get_value_opt(0) + - self.rocket.center_of_dry_mass_position + ) + * self.rocket._csys + ) + inertia = np.empty(len(self.time)) + for i, t in enumerate(self.time): + propellant_mass = self.rocket.motor.propellant_mass.get_value_opt(t) + total = propellant_mass + dry_mass + mu = (propellant_mass * dry_mass / total) if total > 0 else 0.0 + inertia[i] = ( + dry_lateral_inertia + + motor_lateral_inertia.get_value_opt(t) + + mu * b**2 + ) + return inertia + + def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): + """Linearized oscillator coefficients for one plane, as arrays over + ``self.time``: corrective moment coefficient ``C1`` (restoring moment per + radian), damping moment coefficient ``C2`` (aerodynamic + jet), undamped + natural frequency ``omega_n`` and damping ratio ``zeta``. + + ``lift_slope`` is the rocket's total normal-force-curve slope for the + plane (``total_lift_coeff_der`` for pitch, ``total_side_coeff_der`` for + yaw); ``stability_margin`` is the matching linear margin + ``Function(mach, time)``; ``lateral_inertia`` is the array from + :meth:`_lateral_inertia`. + """ + area = self.rocket.area + diameter = 2 * self.rocket.radius + csys = self.rocket._csys + nozzle_position = self.rocket.nozzle_position + mass_flow_rate = self.rocket.motor.total_mass_flow_rate + + corrective = np.empty(len(self.time)) + damping = np.empty(len(self.time)) + for i, t in enumerate(self.time): + mach = self.mach_number.get_value_opt(t) + dynamic_pressure = self.dynamic_pressure.get_value_opt(t) + speed = self.speed.get_value_opt(t) + density = self.density.get_value_opt(t) + center_of_mass = self.rocket.center_of_mass.get_value_opt(t) + + # Corrective moment per radian: q A C_Nalpha (x_cm - x_ac). + margin = stability_margin.get_value_opt(mach, t) # calibers + corrective[i] = ( + dynamic_pressure + * area + * lift_slope.get_value_opt(mach) + * margin + * diameter + ) + + # Aerodynamic damping: 0.5 rho V A sum_i (A_i/A) C_Nalpha_i arm_i^2. + damping_aero = 0.0 + for surface, position in self.rocket.aerodynamic_surfaces: + slope = surface.lift_coefficient_derivative.get_value_opt(mach) + cp_position = ( + position.z + - csys * surface.center_of_pressure_z.get_value_opt(mach) + ) + arm = cp_position - center_of_mass + ref_factor = surface.reference_area / area + damping_aero += ref_factor * slope * arm**2 + damping_aero *= 0.5 * density * speed * area + + # Jet (propulsive) damping: mdot (x_nozzle - x_cm)^2. + damping_jet = abs(mass_flow_rate.get_value_opt(t)) * ( + nozzle_position - center_of_mass + ) ** 2 + damping[i] = damping_aero + damping_jet + + positive_corrective = np.clip(corrective, 0.0, None) + with np.errstate(divide="ignore", invalid="ignore"): + natural_frequency = np.sqrt(positive_corrective / lateral_inertia) + denominator = 2.0 * np.sqrt(positive_corrective * lateral_inertia) + damping_ratio = np.divide( + damping, + denominator, + out=np.zeros_like(damping), + where=denominator > 0, + ) + return corrective, damping, natural_frequency, damping_ratio + + @funcify_method("Time (s)", "Corrective Moment Coefficient (N m/rad)", "linear") + def corrective_moment_coefficient(self): + """Pitch-plane corrective (restoring) moment coefficient ``C1`` as a + function of time -- the aerodynamic restoring moment per radian of angle + of attack. Positive for a statically stable rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + corrective, _, _, _ = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, corrective)) + + @funcify_method("Time (s)", "Damping Moment Coefficient (N m s/rad)", "linear") + def damping_moment_coefficient(self): + """Pitch-plane damping moment coefficient ``C2`` as a function of time -- + the moment opposing the pitch rate, summing aerodynamic damping (from + every surface) and propulsive (jet) damping.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, damping, _, _ = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, damping)) + + @funcify_method("Time (s)", "Pitch Natural Frequency (rad/s)", "linear") + def pitch_natural_frequency(self): + """Undamped natural frequency of the pitch oscillation, + ``omega_n = sqrt(C1 / I_L)``, as a function of time (rad/s).""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, _, natural_frequency, _ = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, natural_frequency)) + + @funcify_method("Time (s)", "Pitch Damping Ratio", "linear") + def pitch_damping_ratio(self): + """Damping ratio of the pitch oscillation, + ``zeta = C2 / (2 sqrt(C1 I_L))``, as a function of time. ``zeta < 1`` is + underdamped (oscillatory), ``zeta > 1`` overdamped.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, _, _, damping_ratio = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, damping_ratio)) + + @funcify_method("Time (s)", "Yaw Natural Frequency (rad/s)", "linear") + def yaw_natural_frequency(self): + """Undamped natural frequency of the yaw oscillation as a function of + time (rad/s). Equals :meth:`pitch_natural_frequency` for an axisymmetric + rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_22, self.rocket.motor.I_22) + _, _, natural_frequency, _ = self._dynamic_stability( + self.rocket.total_side_coeff_der, + self.rocket.stability_margin_yaw, + inertia, + ) + return np.column_stack((self.time, natural_frequency)) + + @funcify_method("Time (s)", "Yaw Damping Ratio", "linear") + def yaw_damping_ratio(self): + """Damping ratio of the yaw oscillation as a function of time. Equals + :meth:`pitch_damping_ratio` for an axisymmetric rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_22, self.rocket.motor.I_22) + _, _, _, damping_ratio = self._dynamic_stability( + self.rocket.total_side_coeff_der, + self.rocket.stability_margin_yaw, + inertia, + ) + return np.column_stack((self.time, damping_ratio)) + # Rail Button Forces @cached_property diff --git a/rocketpy/simulation/helpers/flight_derivatives.py b/rocketpy/simulation/helpers/flight_derivatives.py index 1426e2cd3..ac28b08cd 100644 --- a/rocketpy/simulation/helpers/flight_derivatives.py +++ b/rocketpy/simulation/helpers/flight_derivatives.py @@ -47,6 +47,65 @@ def _compute_drag_7d_inputs( return alpha, beta, stream_mach, reynolds +def _aerodynamic_drag_force( + flight, time, rho, stream_speed, alpha, beta, mach, reynolds, omega +): + """Total rocket axial aerodynamic (drag) force, including air brakes. + + Selects the power-on/power-off drag curve based on the motor burn state, and + then adds (or, when ``override_rocket_drag`` is set, substitutes) the drag of + any deployed air brakes, evaluated through the generic-surface coefficient + machinery. + + Parameters + ---------- + flight : Flight + Flight object providing the rocket. + time : float + Simulation time, used to select the power-on vs power-off drag curve. + rho : float + Air density. + stream_speed : float + Freestream speed magnitude. + alpha, beta, mach, reynolds : float + Standard aerodynamic coefficient inputs at the current state. + omega : tuple of float + Body angular rates ``(omega1, omega2, omega3)``. + + Returns + ------- + float + The axial (body z) aerodynamic drag force. + """ + rocket = flight.rocket + if time < rocket.motor.burn_out_time: + drag_coefficient = rocket.power_on_drag_7d( + alpha, beta, mach, reynolds, omega[0], omega[1], omega[2] + ) + else: + drag_coefficient = rocket.power_off_drag_7d( + alpha, beta, mach, reynolds, omega[0], omega[1], omega[2] + ) + drag_force = -0.5 * rho * stream_speed**2 * rocket.area * drag_coefficient + + # Air brakes are drag-only and may override the rocket drag. + for air_brakes in rocket.air_brakes: + if air_brakes.deployment_level > 0: + air_brakes_cd = air_brakes.cD.get_value_opt( + *air_brakes._coefficient_arguments( + alpha, beta, mach, reynolds, omega[0], omega[1], omega[2] + ) + ) + air_brakes_force = ( + -0.5 * rho * stream_speed**2 * air_brakes.reference_area * air_brakes_cd + ) + if air_brakes.override_rocket_drag: + drag_force = air_brakes_force # Substitutes rocket drag + else: + drag_force += air_brakes_force + return drag_force + + def udot_rail1(flight, t, u, post_processing=False): """Compute the 1-DOF rail-flight state derivative. @@ -282,43 +341,10 @@ def u_dot(flight, t, u, post_processing=False): rho, dynamic_viscosity, ) - if t < flight.rocket.motor.burn_out_time: - drag_coeff = flight.rocket.power_on_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) - else: - drag_coeff = flight.rocket.power_off_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) - R3 = -0.5 * rho * (free_stream_speed**2) * flight.rocket.area * drag_coeff - for air_brakes in flight.rocket.air_brakes: - if air_brakes.deployment_level > 0: - air_brakes_cd = air_brakes.drag_coefficient.get_value_opt( - air_brakes.deployment_level, free_stream_mach - ) - air_brakes_force = ( - -0.5 - * rho - * (free_stream_speed**2) - * air_brakes.reference_area - * air_brakes_cd - ) - if air_brakes.override_rocket_drag: - R3 = air_brakes_force # Substitutes rocket drag coefficient - else: - R3 += air_brakes_force + R3 = _aerodynamic_drag_force( + flight, t, rho, free_stream_speed, alpha, beta, mach, reynolds, + (omega1, omega2, omega3), + ) # Off center moment M1 += flight.rocket.cp_eccentricity_y * R3 M2 -= flight.rocket.cp_eccentricity_x * R3 @@ -549,31 +575,12 @@ def u_dot_generalized_3dof(flight, t, u, post_processing=False): dynamic_viscosity, ) - # Drag computation - if t < flight.rocket.motor.burn_out_time: - cd = flight.rocket.power_on_drag_7d( - alpha, beta, mach, reynolds, omega1, omega2, omega3 - ) - else: - cd = flight.rocket.power_off_drag_7d( - alpha, beta, mach, reynolds, omega1, omega2, omega3 - ) - + # Drag computation (rocket body drag + air brakes) R1, R2 = 0, 0 - R3 = -0.5 * rho * free_stream_speed**2 * flight.rocket.area * cd - - for air_brake in flight.rocket.air_brakes: - if air_brake.deployment_level > 0: - ab_cd = air_brake.drag_coefficient.get_value_opt( - air_brake.deployment_level, mach - ) - ab_force = ( - -0.5 * rho * free_stream_speed**2 * air_brake.reference_area * ab_cd - ) - if air_brake.override_rocket_drag: - R3 = ab_force - else: - R3 += ab_force + R3 = _aerodynamic_drag_force( + flight, t, rho, free_stream_speed, alpha, beta, mach, reynolds, + (omega1, omega2, omega3), + ) # Velocity in body frame vb_body = Kt @ v @@ -806,43 +813,12 @@ def u_dot_generalized(flight, t, u, post_processing=False): + flight.rocket.motor.pressure_thrust(pressure), 0, ) - drag_coeff = flight.rocket.power_on_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) else: net_thrust = 0 - drag_coeff = flight.rocket.power_off_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) - R3 += -0.5 * rho * (free_stream_speed**2) * flight.rocket.area * drag_coeff - for air_brakes in flight.rocket.air_brakes: - if air_brakes.deployment_level > 0: - air_brakes_cd = air_brakes.drag_coefficient.get_value_opt( - air_brakes.deployment_level, free_stream_mach - ) - air_brakes_force = ( - -0.5 - * rho - * (free_stream_speed**2) - * air_brakes.reference_area - * air_brakes_cd - ) - if air_brakes.override_rocket_drag: - R3 = air_brakes_force # Substitutes rocket drag coefficient - else: - R3 += air_brakes_force + R3 = _aerodynamic_drag_force( + flight, t, rho, free_stream_speed, alpha, beta, mach, reynolds, + (omega1, omega2, omega3), + ) # Get rocket velocity in body frame velocity_in_body_frame = Kt @ v # Calculate lift and moment for each component of the rocket From b45178fa5d6667d4207c32356e37d935a64a4d74 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Sat, 27 Jun 2026 15:52:10 -0300 Subject: [PATCH 2/8] ENH: second draft --- rocketpy/plots/aero_surface_plots.py | 85 +- rocketpy/plots/flight_plots.py | 11 +- rocketpy/plots/rocket_plots.py | 99 +-- rocketpy/prints/aero_surface_prints.py | 96 +-- rocketpy/rocket/aero_surface/__init__.py | 6 +- .../rocket/aero_surface/_barrowman_surface.py | 8 +- .../rocket/aero_surface/aero_coefficient.py | 471 +++++++++-- rocketpy/rocket/aero_surface/air_brakes.py | 9 +- .../controllable_generic_surface.py | 15 +- rocketpy/rocket/aero_surface/fins/fin.py | 26 +- .../rocket/aero_surface/generic_surface.py | 233 +++-- .../aero_surface/linear_generic_surface.py | 85 +- rocketpy/rocket/aero_surface/rail_buttons.py | 4 + rocketpy/rocket/point_mass_rocket.py | 8 +- rocketpy/rocket/rocket.py | 795 +++++------------- rocketpy/simulation/flight.py | 44 +- .../simulation/helpers/flight_derivatives.py | 43 +- 17 files changed, 942 insertions(+), 1096 deletions(-) diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index fe9eec783..a3753d660 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -1,6 +1,4 @@ # pylint: disable=too-many-statements -from abc import ABC, abstractmethod - import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Ellipse @@ -8,16 +6,16 @@ from .plot_helpers import show_or_save_plot -class _AeroSurfacePlots(ABC): - """Abstract class that contains all aero surface plots.""" +class _GenericSurfacePlots: + """Base plots for a generic aerodynamic surface.""" def __init__(self, aero_surface): """Initialize the class Parameters ---------- - aero_surface : rocketpy.AeroSurface - AeroSurface object to be plotted + aero_surface : rocketpy.GenericSurface + Aerodynamic surface object to be plotted Returns ------- @@ -25,20 +23,8 @@ def __init__(self, aero_surface): """ self.aero_surface = aero_surface - @abstractmethod def draw(self, *, filename=None): - pass - - def lift(self): - """Plots the lift coefficient of the aero surface as a function of Mach - and the angle of attack. A 3D plot is expected. See the rocketpy.Function - class for more information on how this plot is made. - - Returns - ------- - None - """ - self.aero_surface.cl() + """A plain generic surface has no geometry to draw.""" # Coefficients swept against their most relevant incidence angle: pitch-plane # coefficients vs. angle of attack, yaw-plane ones vs. sideslip. @@ -115,20 +101,40 @@ def coefficients(self, *, mach=0.3, angle_range_deg=15.0, filename=None): show_or_save_plot(filename) def all(self): - """Plots all aero surface plots. + """Plots the generic surface's aerodynamic coefficients.""" + self.coefficients() + + +class _LinearGenericSurfacePlots(_GenericSurfacePlots): + """Plots for a linear generic surface; same plots as the generic base.""" + + +class _BarrowmanSurfacePlots(_LinearGenericSurfacePlots): + """Plots shared by the geometry-defined (Barrowman) surfaces: adds the + geometry drawing and the lift-coefficient surface plot.""" + + def lift(self): + """Plots the lift coefficient of the aero surface as a function of Mach + and the angle of attack. A 3D plot is expected. See the rocketpy.Function + class for more information on how this plot is made. Returns ------- None """ + self.aero_surface.cl() + + def all(self): + """Plots the surface geometry, the lift coefficient and the + aerodynamic coefficients.""" self.draw() self.lift() self.coefficients() -class _NoseConePlots(_AeroSurfacePlots): +class _NoseConePlots(_BarrowmanSurfacePlots): """Class that contains all nosecone plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def draw(self, *, filename=None): """Draw the nosecone shape along with some important information, @@ -211,9 +217,9 @@ def draw(self, *, filename=None): show_or_save_plot(filename) -class _FinsPlots(_AeroSurfacePlots): +class _FinsPlots(_BarrowmanSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def airfoil(self, *, filename=None): """Plots the airfoil information when the fin has an airfoil shape. If @@ -293,9 +299,9 @@ def all(self, *, filename=None): self.coefficients(filename=filename) -class _FinPlots(_AeroSurfacePlots): +class _FinPlots(_BarrowmanSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def airfoil(self, *, filename=None): """Plots the airfoil information when the fin has an airfoil shape. If @@ -918,7 +924,7 @@ def draw(self, *, filename=None): show_or_save_plot(filename) -class _TailPlots(_AeroSurfacePlots): +class _TailPlots(_BarrowmanSurfacePlots): """Class that contains all tail plots.""" def draw(self, *, filename=None): @@ -926,7 +932,7 @@ def draw(self, *, filename=None): pass -class _AirBrakesPlots(_AeroSurfacePlots): +class _AirBrakesPlots(_GenericSurfacePlots): """Class that contains all air brakes plots.""" def drag_coefficient_curve(self): @@ -947,26 +953,3 @@ def all(self): None """ self.drag_coefficient_curve() - - -class _GenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all generic surface plots.""" - - def draw(self, *, filename=None): - pass - - def all(self): - """Plots all generic surface plots (the aerodynamic coefficients).""" - self.coefficients() - - -class _LinearGenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all linear generic surface plots.""" - - def draw(self, *, filename=None): - pass - - def all(self): - """Plots all linear generic surface plots (the aerodynamic - coefficients).""" - self.coefficients() diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 681d08b13..f162f268b 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -1366,15 +1366,20 @@ def dynamic_stability_data(self, *, filename=None): if asymmetric: yaw_freq = self.flight.yaw_natural_frequency ax1.plot( - yaw_freq[:, 0], yaw_freq[:, 1] / (2 * np.pi), "--", + yaw_freq[:, 0], + yaw_freq[:, 1] / (2 * np.pi), + "--", label="Yaw natural freq.", ) # Roll rate as a frequency: where it crosses the natural frequency the # rocket is in roll resonance (roll-pitch/yaw coupling). roll_rate = self.flight.w3 ax1.plot( - roll_rate[:, 0], np.abs(roll_rate[:, 1]) / (2 * np.pi), ":", - color="tab:red", label="Roll rate (resonance if crossing)", + roll_rate[:, 0], + np.abs(roll_rate[:, 1]) / (2 * np.pi), + ":", + color="tab:red", + label="Roll rate (resonance if crossing)", ) ax1.set_title("Natural Frequency & Roll Rate") ax1.set_xlabel("Time (s)") diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 561732534..95a746151 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -125,78 +125,6 @@ def stability_margin_yaw(self): alpha=1, ) - def stability_margin_over_alpha(self, *, filename=None): - """Plots the stability margin in calibers as a function of angle of - attack, for a range of Mach numbers. Built on the nonlinear center of - pressure, it shows how the margin changes with incidence -- the - angle-of-attack analogue of the static margin, which a Barrowman - (Mach-only) estimate cannot capture. Evaluated at the loaded center of - mass (``time = 0``). - - Parameters - ---------- - filename : str | None, optional - The path the plot should be saved to. By default None, in which case - the plot will be shown instead of saved. Supported file endings are: - eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff - and webp (these are the formats supported by matplotlib). - - Returns - ------- - None - """ - alphas_deg = np.linspace(0, 15, 50) - alphas_rad = np.radians(alphas_deg) - - _, ax = plt.subplots() - for mach in (0.1, 0.5, 0.8, 1.2, 2.0): - margin = self.rocket.stability_margin_over_alpha(mach=mach) - margin_values = [margin.get_value_opt(a) for a in alphas_rad] - ax.plot(alphas_deg, margin_values, label=f"Mach {mach}") - - ax.set_title("Stability Margin vs Angle of Attack (loaded)") - ax.set_xlabel("Angle of Attack (deg)") - ax.set_ylabel("Stability Margin (c)") - ax.legend(loc="best", shadow=True) - plt.grid(True) - show_or_save_plot(filename) - - def stability_margin_over_beta(self, *, filename=None): - """Plots the stability margin in calibers as a function of sideslip - angle, for a range of Mach numbers -- the yaw-plane companion to - :meth:`stability_margin_over_alpha`. Most informative for - non-axisymmetric rockets, whose yaw-plane center of pressure differs - from the pitch-plane one. Evaluated at the loaded center of mass - (``time = 0``). - - Parameters - ---------- - filename : str | None, optional - The path the plot should be saved to. By default None, in which case - the plot will be shown instead of saved. Supported file endings are: - eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff - and webp (these are the formats supported by matplotlib). - - Returns - ------- - None - """ - betas_deg = np.linspace(0, 15, 50) - betas_rad = np.radians(betas_deg) - - _, ax = plt.subplots() - for mach in (0.1, 0.5, 0.8, 1.2, 2.0): - margin = self.rocket.stability_margin_over_beta(mach=mach) - margin_values = [margin.get_value_opt(b) for b in betas_rad] - ax.plot(betas_deg, margin_values, label=f"Mach {mach}") - - ax.set_title("Stability Margin vs Sideslip Angle (loaded)") - ax.set_xlabel("Sideslip Angle (deg)") - ax.set_ylabel("Stability Margin (c)") - ax.legend(loc="best", shadow=True) - plt.grid(True) - show_or_save_plot(filename) - # pylint: disable=too-many-statements def drag_curves(self, *, filename=None): """Plots power off and on drag curves of the rocket as a function of time. @@ -275,8 +203,7 @@ def aerodynamic_coefficients(self, *, filename=None): _, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5)) for mach in (0.1, 0.5, 0.8, 1.2, 2.0): coeffs = [ - self.rocket.aerodynamic_coefficients(a, 0.0, mach) - for a in alphas_rad + self.rocket.aerodynamic_coefficients(a, 0.0, mach) for a in alphas_rad ] ax1.plot( alphas_deg, [c["normal_force"] for c in coeffs], label=f"Mach {mach}" @@ -845,24 +772,24 @@ def _draw_center_of_mass_and_pressure(self, ax, plane="xz"): label="Center of Pressure Range", ) - ax.scatter( - cp, 0, label="Center of Pressure", color="red", s=10, zorder=10 - ) + ax.scatter(cp, 0, label="Center of Pressure", color="red", s=10, zorder=10) def _center_of_pressure_range(self, plane, max_angle=np.deg2rad(15), samples=31): """Min and max center-of-pressure position over an incidence sweep. Sweeps the angle of attack (xz plane) or sideslip (yz plane) from 0 to - ``max_angle`` using :meth:`Rocket.center_of_pressure_over_alpha` / - :meth:`Rocket.center_of_pressure_over_beta` and returns the extent of - the resulting center-of-pressure travel. + ``max_angle`` using the nonlinear :meth:`Rocket.center_of_pressure` and + returns the extent of the resulting center-of-pressure travel. """ + angles = np.linspace(0, max_angle, samples) if plane == "yz": - cp_travel = self.rocket.center_of_pressure_over_beta() + positions = np.array( + [self.rocket.center_of_pressure(0.0, b, 0.0) for b in angles] + ) else: - cp_travel = self.rocket.center_of_pressure_over_alpha() - angles = np.linspace(0, max_angle, samples) - positions = np.array([cp_travel.get_value_opt(a) for a in angles]) + positions = np.array( + [self.rocket.center_of_pressure(a, 0.0, 0.0) for a in angles] + ) positions = positions[np.isfinite(positions)] if len(positions) == 0: return (0.0, 0.0) @@ -956,13 +883,11 @@ def all(self): print("-" * 20) # Separator for Stability Plots self.static_margin() self.stability_margin() - self.stability_margin_over_alpha() # Non-axisymmetric rockets: the above describe the pitch plane only, so - # also show the yaw-plane margins (including the sideslip sweep). + # also show the yaw-plane margins. if not self.rocket.is_axisymmetric: self.static_margin_yaw() self.stability_margin_yaw() - self.stability_margin_over_beta() # Thrust-to-Weight Plot print("\nThrust-to-Weight Plot") diff --git a/rocketpy/prints/aero_surface_prints.py b/rocketpy/prints/aero_surface_prints.py index d85b6e243..12a2ba5c5 100644 --- a/rocketpy/prints/aero_surface_prints.py +++ b/rocketpy/prints/aero_surface_prints.py @@ -1,10 +1,16 @@ -from abc import ABC, abstractmethod - import numpy as np -# TODO: the rocketpy/prints/aero_surface_prints.py file could be separated into different, smaller files. -class _AeroSurfacePrints(ABC): +# The print classes mirror the aerodynamic-surface class hierarchy: +# GenericSurface -> _GenericSurfacePrints (root) +# LinearGenericSurface -> _LinearGenericSurfacePrints +# _BarrowmanSurface -> _BarrowmanSurfacePrints (adds clalpha/CP lift) +# NoseCone / Tail / Fins/Fin -> the leaf print classes below +# ControllableGenericSurface / AirBrakes / RailButtons -> generic-rooted leaves +# TODO: this file could be separated into different, smaller files. +class _GenericSurfacePrints: + """Base prints for a generic aerodynamic surface.""" + def __init__(self, aero_surface): self.aero_surface = aero_surface @@ -59,9 +65,33 @@ def identity(self): print(f"Name: {self.aero_surface.name}") print(f"Python Class: {str(self.aero_surface.__class__)}\n") - @abstractmethod def geometry(self): - pass + """Prints the reference geometry of the generic surface.""" + print("Geometric information of the Surface:") + print("----------------------------------") + print(f"Reference Area: {self.aero_surface.reference_area:.3f} m^2") + print(f"Reference length: {self.aero_surface.reference_length:.3f} m\n") + + def all(self): + """Prints all information of the generic surface. + + Returns + ------- + None + """ + self.identity() + self.geometry() + self.coefficients() + + +class _LinearGenericSurfacePrints(_GenericSurfacePrints): + """Prints for a linear generic surface; same reporting as the generic + base.""" + + +class _BarrowmanSurfacePrints(_LinearGenericSurfacePrints): + """Prints shared by the geometry-defined (Barrowman) surfaces: adds the + center-of-pressure / lift-curve-slope report on top of the generic base.""" def lift(self): """Prints the lift information of the aero surface. @@ -95,7 +125,7 @@ def all(self): self.coefficients() -class _NoseConePrints(_AeroSurfacePrints): +class _NoseConePrints(_BarrowmanSurfacePrints): """Class that contains all nosecone prints.""" def geometry(self): @@ -114,7 +144,7 @@ def geometry(self): print(f"Reference radius ratio: {self.aero_surface.radius_ratio:.3f}\n") -class _FinsPrints(_AeroSurfacePrints): +class _FinsPrints(_BarrowmanSurfacePrints): def geometry(self): print("Geometric information of the fin set:") print("-------------------------------------") @@ -212,7 +242,7 @@ def all(self): self.lift() -class _FinPrints(_AeroSurfacePrints): +class _FinPrints(_BarrowmanSurfacePrints): def geometry(self): print("Geometric information of the fin set:") print("-------------------------------------") @@ -333,7 +363,7 @@ class _FreeFormFinPrints(_FinPrints): """Class that contains all free form fins prints.""" -class _TailPrints(_AeroSurfacePrints): +class _TailPrints(_BarrowmanSurfacePrints): """Class that contains all tail prints.""" def geometry(self): @@ -353,7 +383,7 @@ def geometry(self): print(f"Surface area: {self.aero_surface.surface_area:.6f} m²\n") -class _RailButtonsPrints(_AeroSurfacePrints): +class _RailButtonsPrints(_GenericSurfacePrints): """Class that contains all rail buttons prints.""" def geometry(self): @@ -369,7 +399,7 @@ def geometry(self): ) -class _AirBrakesPrints(_AeroSurfacePrints): +class _AirBrakesPrints(_GenericSurfacePrints): """Class that contains all air_brakes prints. Not yet implemented.""" def geometry(self): @@ -377,45 +407,3 @@ def geometry(self): def all(self): pass - - -class _GenericSurfacePrints(_AeroSurfacePrints): - """Class that contains all generic surface prints.""" - - def geometry(self): - print("Geometric information of the Surface:") - print("----------------------------------") - print(f"Reference Area: {self.aero_surface.reference_area:.3f} m^2") - print(f"Reference length: {self.aero_surface.reference_length:.3f} m\n") - - def all(self): - """Prints all information of the generic surface. - - Returns - ------- - None - """ - self.identity() - self.geometry() - self.coefficients() - - -class _LinearGenericSurfacePrints(_AeroSurfacePrints): - """Class that contains all linear generic surface prints.""" - - def geometry(self): - print("Geometric information of the Surface:") - print("----------------------------------") - print(f"Reference Area: {self.aero_surface.reference_area:.3f} m^2") - print(f"Reference length: {self.aero_surface.reference_length:.3f} m\n") - - def all(self): - """Prints all information of the linear generic surface. - - Returns - ------- - None - """ - self.identity() - self.geometry() - self.coefficients() diff --git a/rocketpy/rocket/aero_surface/__init__.py b/rocketpy/rocket/aero_surface/__init__.py index 542435781..7a6e7ac2d 100644 --- a/rocketpy/rocket/aero_surface/__init__.py +++ b/rocketpy/rocket/aero_surface/__init__.py @@ -1,5 +1,8 @@ from rocketpy.rocket.aero_surface.aero_surface import AeroSurface from rocketpy.rocket.aero_surface.air_brakes import AirBrakes +from rocketpy.rocket.aero_surface.controllable_generic_surface import ( + ControllableGenericSurface, +) from rocketpy.rocket.aero_surface.fins import ( EllipticalFin, EllipticalFins, @@ -10,9 +13,6 @@ TrapezoidalFin, TrapezoidalFins, ) -from rocketpy.rocket.aero_surface.controllable_generic_surface import ( - ControllableGenericSurface, -) from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface from rocketpy.rocket.aero_surface.nose_cone import NoseCone diff --git a/rocketpy/rocket/aero_surface/_barrowman_surface.py b/rocketpy/rocket/aero_surface/_barrowman_surface.py index 9a9843193..3ae96024b 100644 --- a/rocketpy/rocket/aero_surface/_barrowman_surface.py +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -27,6 +27,11 @@ class _BarrowmanSurface(LinearGenericSurface): ``self.roll_parameters = [clf_delta, cld_omega, cant_angle_rad]``. """ + # Geometry-defined Barrowman surfaces are axisymmetric by construction + # (``cQ_beta = -cL_alpha``, etc.), so they contribute identically to the + # pitch and yaw planes. The individual ``Fin`` overrides this back to False. + is_axisymmetric = True + @staticmethod def _beta(mach): """Prandtl-Glauert compressibility factor used to correct subsonic @@ -115,6 +120,7 @@ def _mach_coefficient(self, func_of_mach, name="coefficient"): return AeroCoefficient( func_of_mach, depends_on=("mach",), - independent_vars=self.independent_vars, + unsteady_aero=self._unsteady_aero, + control_variables=self.control_variables, name=name, ) diff --git a/rocketpy/rocket/aero_surface/aero_coefficient.py b/rocketpy/rocket/aero_surface/aero_coefficient.py index e65aa3a9a..fa0b35012 100644 --- a/rocketpy/rocket/aero_surface/aero_coefficient.py +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -13,35 +13,167 @@ selection that the CSV loader used to do inline. """ +import copy +import csv import inspect from rocketpy.mathutils import Function +# Single source of truth for the seven base coefficient independent variables. +BASE_INDEPENDENT_VARS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", +] + + +def build_independent_vars(unsteady_aero=False, control_variables=()): + """Build the ordered independent-variable list of a coefficient/surface. + + The seven base axes (``BASE_INDEPENDENT_VARS``), plus ``alpha_dot`` and + ``beta_dot`` when ``unsteady_aero`` is enabled (axes the flight integrator + supplies automatically), plus any ``control_variables`` (axes supplied + externally, e.g. by a controller). Shared by :class:`AeroCoefficient` and + :class:`GenericSurface` so the ordering is defined in exactly one place. + """ + names = list(BASE_INDEPENDENT_VARS) + if unsteady_aero: + names += ["alpha_dot", "beta_dot"] + names += list(control_variables) + return names + class AeroCoefficient: """A single aerodynamic coefficient stored at minimal dimensionality. - Parameters - ---------- - source : int, float, callable, or Function - The coefficient value. A number is stored as a constant; a callable or - :class:`Function` is stored over ``depends_on``. - depends_on : sequence of str - The independent variables the coefficient depends on, a (possibly - empty) subset of ``independent_vars``. The order is normalized to the - order of ``independent_vars``. - independent_vars : sequence of str - The full, ordered list of independent variables of the owning surface - (e.g. ``alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate`` - plus any control or unsteady axes). Defines the argument order accepted - by :meth:`__call__`. - name : str, optional - Name of the coefficient, used for the underlying ``Function`` output. + Building goes through :meth:`__init__`: pass a raw coefficient input + (number, callable, :class:`Function`, list/tuple of points, CSV path, or + another :class:`AeroCoefficient`) and ``depends_on`` is inferred; pass + ``depends_on`` explicitly only on the fast path where it is already known. """ - def __init__(self, source, depends_on, independent_vars, name="coefficient"): + def __init__( + self, + source, + depends_on=None, + unsteady_aero=False, + control_variables=(), + name="coefficient", + extrapolation=None, + single_var=None, + ): + """Build a coefficient stored at minimal dimensionality. + + A number is kept as a plain constant. Anything else is wrapped in a + :class:`Function` over only the variables it depends on (``depends_on``), + so a Mach-only curve stays 1-D instead of being stretched across all + seven axes. On each call the full argument tuple is mapped down to just + those arguments (using the precomputed ``_indices``). The full, ordered + list of variables comes from ``unsteady_aero`` and ``control_variables`` + via :func:`build_independent_vars`. + + Usually you do not pass ``depends_on``: leave it as ``None`` and it is + worked out from ``source`` (a number, a callable, a :class:`Function`, a + list of points, a CSV path, or another :class:`AeroCoefficient`), the + same inputs :class:`GenericSurface` accepts (see :meth:`_resolve_input`). + Pass ``depends_on`` yourself only on the fast path, where the source and + its argument order are already known (the Barrowman surfaces and + serialization). + + Parameters + ---------- + source : number, str, list, tuple, callable, Function, or AeroCoefficient + The coefficient value, or an input it can be worked out from when + ``depends_on`` is ``None``. The accepted forms are: + + - **number**: kept as a constant. Calls return it directly, and + ``is_zero`` is set when it is exactly ``0.0`` (the linear model + uses that to skip the term). It depends on nothing. + - **callable** (function or ``lambda``): wrapped in a + :class:`Function`. When ``depends_on`` is worked out, the + parameter *names* decide it: name them after the variables they + use (e.g. ``lambda alpha, mach: ...``), give one argument per + variable, or use one argument together with ``single_var``. + - **Function**: used as given. If ``extrapolation`` is set, it is + applied to a copy, never to the object you passed in (it may be + shared elsewhere). + - **list/tuple of points**: turned into a :class:`Function` with + linear interpolation, so a list and the same data in a CSV give + the same result. + - **str**: a path to a data file. A ``.csv`` file is read by the CSV + loader (column headers name the variables; a headerless + two-column file is a 1-D table over ``single_var``); other files + are read by :class:`Function`. + - **AeroCoefficient**: an existing coefficient, re-keyed to this + surface's variables. This is what lets a surface round-trip + through ``to_dict``/``from_dict`` and lets one coefficient be + reused on several surfaces. + depends_on : sequence of str, optional + The variables this coefficient actually uses, a (possibly empty) + subset of the surface's full variable list (set by ``unsteady_aero`` + and ``control_variables``). Keep them in the same order as the + source's own arguments (a callable's parameters, a CSV's columns): + that order is used to pick the right values out of the full argument + tuple on each call. For example, ``()`` for a constant, ``("mach",)`` + for a Mach-only curve, or the whole list for something that uses + every variable. A name that is not one of the surface's variables + raises a ``ValueError``. Leave it as ``None`` (the default) to have + it worked out from ``source``; pass it only on the fast path, where + the source and its argument order are already known. + unsteady_aero : bool, optional + Add the unsteady axes to this coefficient's variables. When ``True``, + ``alpha_dot`` and ``beta_dot`` (the rates of change of the angle of + attack and sideslip) are added after the seven base axes, so calls + take two more arguments. The flight integrator fills these in, using + ``0`` when it does not compute them, so ordinary tables keep working. + Match the owning surface's setting. Default ``False``. + control_variables : sequence of str, optional + Names of extra axes supplied from outside, such as control-surface + deflections from a controller. They are added after the base and + unsteady axes, and each one becomes an extra call argument, in the + order given. Used by :class:`ControllableGenericSurface` and air + brakes; empty for ordinary surfaces. Default ``()``. + name : str, optional + A readable name for the coefficient (e.g. ``"cL_alpha"`` or + ``"Drag Coefficient with Power Off"``). It labels the underlying + :class:`Function` and appears in error messages, so a clear name + makes problems easier to spot. Default ``"coefficient"``. + extrapolation : str, optional + How the stored :class:`Function` behaves outside its data range, one + of the options of :meth:`Function.set_extrapolation`: ``"constant"`` + holds the edge value (used for drag, which should not run past its + data), ``"natural"`` keeps following the curve, ``"zero"`` returns + ``0``. ``None`` (the default) leaves a :class:`Function` you passed + in unchanged, and uses ``"natural"`` for one built from a callable. + An override is always applied to a copy, so your object is never + changed. + single_var : str, optional + Which variable a 1-D input maps to. Used only while working out + ``depends_on`` for a single-dimension source: a headerless + two-column CSV, a 1-D :class:`Function`, or a one-argument callable. + ``None`` (the default) guesses it from the input's label, falling + back to the first variable; drag passes ``"mach"`` so a plain + Cd-vs-Mach curve maps to Mach. Ignored when ``depends_on`` is given. + Default ``None``. + """ self.name = name - self.independent_vars = tuple(independent_vars) + self.extrapolation = extrapolation + self.unsteady_aero = unsteady_aero + self.control_variables = tuple(control_variables) + self.independent_vars = tuple( + build_independent_vars(unsteady_aero, control_variables) + ) + # Infer the stored source and its dependencies from the raw input when + # ``depends_on`` is not given. ``_resolve_input`` may also adopt the + # input's extrapolation (re-keying an AeroCoefficient), so refresh the + # local ``extrapolation`` used by the source-storage block below. + if depends_on is None: + source, depends_on = self._resolve_input(source, single_var) + extrapolation = self.extrapolation # ``depends_on`` is kept in the given order because it matches the # positional argument order of the stored source (callable parameters, # CSV columns, …). ``_indices`` therefore maps the full argument tuple @@ -60,6 +192,13 @@ def __init__(self, source, depends_on, independent_vars, name="coefficient"): self.is_zero = False self._constant = None if isinstance(source, Function): + # Only override extrapolation when explicitly asked, and on a copy: + # the source may be a user-owned Function reused elsewhere, so + # mutating it in place (e.g. drag forcing "constant") would change + # its behavior everywhere the caller reuses it. + if extrapolation is not None: + source = copy.deepcopy(source) + source.set_extrapolation(extrapolation) self.function = source elif callable(source): self.function = Function( @@ -67,7 +206,7 @@ def __init__(self, source, depends_on, independent_vars, name="coefficient"): list(self.depends_on) or ["x"], [name], interpolation="linear", - extrapolation="natural", + extrapolation=extrapolation or "natural", ) else: # Scalar constant. @@ -77,83 +216,201 @@ def __init__(self, source, depends_on, independent_vars, name="coefficient"): self._evaluate = self.function.get_value_opt - @classmethod - def from_input(cls, input_data, name, independent_vars, csv_loader=None): - """Build an :class:`AeroCoefficient` from a user coefficient input. + def _resolve_input(self, source, single_var): + """Infer ``(stored source, depends_on)`` from a raw coefficient input. + + Mirrors the coefficient inputs accepted by :class:`GenericSurface`: a + number, a callable, a :class:`Function`, a list/tuple of data points, a + path to a CSV (or other text) file, or another :class:`AeroCoefficient` + (re-keyed). + Called by :meth:`__init__` when ``depends_on`` is omitted; the returned + ``source`` is a number, a callable or a :class:`Function`, which the + constructor's source-storage block then stores. + """ + name = self.name + independent_vars = self.independent_vars + n_vars = len(independent_vars) + + if isinstance(source, AeroCoefficient): + # An already-built coefficient passed straight through, re-keyed to + # this surface's variable order. This is how a *surface* round-trips: + # GenericSurface/ControllableGenericSurface store their processed + # AeroCoefficients in ``to_dict`` and feed them back on ``from_dict`` + # (and a user may reuse one coefficient across surfaces). Adopt its + # extrapolation when none was requested. + if self.extrapolation is None: + self.extrapolation = source.extrapolation + value = ( + source._constant if source._constant is not None else source.function + ) + return value, source.depends_on - Mirrors the accepted coefficient inputs of - :class:`GenericSurface`: a number, a callable, a :class:`Function`, or a - path to a CSV file, inferring ``depends_on`` from each. + if isinstance(source, str): + if source.lower().endswith(".csv"): + return self._load_csv( + source, + name, + independent_vars, + extrapolation=self.extrapolation or "natural", + single_var=single_var, + ) + # Any other path (e.g. a whitespace-delimited ``.txt`` curve) is read + # by Function, which auto-detects the delimiter. Linear interpolation + # matches the CSV loader, so the same data gives identical results + # whatever file form it is given. Falls through to the Function + # branch below (a 1-D table keyed to ``single_var``). + source = Function(source, interpolation="linear") + + # A list/tuple of data points is parsed by Function and handled below. + # Linear interpolation matches the CSV loader, so the same tabular data + # gives identical results whether supplied as a list or a CSV file + # (Function would otherwise default to spline). + if isinstance(source, (list, tuple)): + try: + source = Function(list(source), interpolation="linear") + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid list/tuple input for {name}: could not be parsed " + "into a Function of the independent variables." + ) from exc + + if isinstance(source, Function): + dom_dim = source.__dom_dim__ + if dom_dim == n_vars: + return source, list(independent_vars) + if dom_dim == 1: + # A 1-D Function depends on ``single_var`` when given, else on + # the first independent variable unless its input name matches. + return source, [ + single_var or self._infer_single_var(source, independent_vars) + ] + raise ValueError( + f"{name} Function must have {n_vars} input arguments " + f"({', '.join(independent_vars)}) or be one-dimensional." + ) + + if callable(source): + return source, self._infer_callable_depends_on( + source, independent_vars, name, single_var=single_var + ) + + # Anything else must be a scalar number. + try: + float(source) + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid input for {name}: must be a number, a CSV file path, " + "a list of data points, a callable, or a Function." + ) from exc + return source, () + + @staticmethod + def _load_csv( + file_path, name, independent_vars, extrapolation="natural", single_var=None + ): # pylint: disable=too-many-statements + """Load a coefficient CSV at minimal dimension. + + Expects header-based CSV data whose columns (except the last) are + independent variables among ``independent_vars``; the last column is the + coefficient value. The coefficient is stored over only the columns that + are present, in their header order. A headerless two-column file is + treated as a one-dimensional table over ``single_var``. Parameters ---------- - input_data : int, float, str, callable, or Function - The coefficient value (number, CSV path, callable, or Function). + file_path : str + Path to the CSV file. name : str Coefficient name, used for error messages and the Function output. independent_vars : sequence of str - The owning surface's ordered independent variables. - csv_loader : callable, optional - Callable ``(file_path, name) -> (function, depends_on)`` used to - load a CSV coefficient at minimal dimension. Required when - ``input_data`` is a string path. + The owning surface's ordered independent variables, used to validate + the CSV header columns. + extrapolation : str, optional + Extrapolation method for the loaded ``Function``. Defaults to + ``"natural"``; drag coefficients pass ``"constant"``. + single_var : str, optional + Independent variable a headerless two-column table depends on. + Defaults to the first independent variable. Returns ------- - AeroCoefficient + tuple + ``(function, depends_on)`` where ``function`` is a low-dimensional + ``Function`` over the present columns and ``depends_on`` lists those + columns. Consumed by :meth:`_resolve_input`. """ independent_vars = list(independent_vars) - n_vars = len(independent_vars) - vars_repr = ", ".join(independent_vars) - - if isinstance(input_data, AeroCoefficient): - # Already an AeroCoefficient (e.g. a to_dict/from_dict round trip): - # re-key it to the requested independent-variable order. - return cls( - input_data._constant - if input_data._constant is not None - else input_data.function, - input_data.depends_on, - independent_vars, - name, + + try: + with open(file_path, mode="r") as file: + reader = csv.reader(file) + header = next(reader) + except (FileNotFoundError, IOError) as e: + raise ValueError(f"Error reading {name} CSV file: {e}") from e + except StopIteration as e: + raise ValueError(f"Invalid or empty CSV file for {name}.") from e + + if not header: + raise ValueError(f"Invalid or empty CSV file for {name}.") + + header = [column.strip() for column in header] + + # Headerless two-column (x, coefficient) table: a 1-D table over + # ``single_var`` (e.g. a Mach-only drag curve given as ``mach, cd``). + def _is_numeric(value): + try: + float(value) + return True + except (TypeError, ValueError): + return False + + if len(header) == 2 and all(_is_numeric(cell) for cell in header): + csv_func = Function( + file_path, + interpolation="linear", + extrapolation=extrapolation, ) + return csv_func, [single_var or independent_vars[0]] - if isinstance(input_data, str): - if csv_loader is None: # pragma: no cover - defensive - raise ValueError("A csv_loader is required for CSV coefficients.") - function, depends_on = csv_loader(input_data, name) - return cls(function, depends_on, independent_vars, name) + present_columns = [col for col in independent_vars if col in header] - if isinstance(input_data, Function): - dom_dim = input_data.__dom_dim__ - if dom_dim == n_vars: - depends_on = independent_vars - elif dom_dim == 1: - # A 1-D Function is taken to depend on the first independent - # variable (alpha) unless its input name matches one of them. - depends_on = [cls._infer_single_var(input_data, independent_vars)] - else: - raise ValueError( - f"{name} Function must have {n_vars} input arguments " - f"({vars_repr}) or be one-dimensional." - ) - return cls(input_data, depends_on, independent_vars, name) + invalid_columns = [col for col in header[:-1] if col not in independent_vars] + if invalid_columns: + raise ValueError( + f"Invalid independent variable(s) in {name} CSV: " + f"{invalid_columns}. Valid options are: {independent_vars}." + ) - if callable(input_data): - depends_on = cls._infer_callable_depends_on( - input_data, independent_vars, name + if header[-1] in independent_vars: + raise ValueError( + f"Last column in {name} CSV must be the coefficient" + " value, not an independent variable." ) - return cls(input_data, depends_on, independent_vars, name) - # Anything else must be a scalar number. - try: - float(input_data) - except (TypeError, ValueError) as exc: - raise TypeError( - f"Invalid input for {name}: must be a number, a CSV file path, " - "a callable, or a Function." - ) from exc - return cls(input_data, (), independent_vars, name) + if not present_columns: + raise ValueError(f"No independent variables found in {name} CSV.") + + ordered_present_columns = [ + col for col in header[:-1] if col in independent_vars + ] + + csv_func = Function.from_regular_grid_csv( + file_path, + ordered_present_columns, + name, + extrapolation=extrapolation, + ) + if csv_func is None: + csv_func = Function( + file_path, + interpolation="linear", + extrapolation=extrapolation, + ) + + # The CSV columns may appear in any order; AeroCoefficient maps the full + # argument tuple to ``ordered_present_columns`` order, so the stored + # Function is queried directly at its own (minimal) dimensionality. + return csv_func, ordered_present_columns @staticmethod def _infer_single_var(function, independent_vars): @@ -163,17 +420,26 @@ def _infer_single_var(function, independent_vars): except (AttributeError, IndexError, TypeError): return independent_vars[0] label_lower = str(label).lower() + # Exact match first; then substring, longest variable name first, so a + # label like "alpha_dot" binds to "alpha_dot" rather than the shorter + # substring "alpha". for var in independent_vars: + if var == label_lower: + return var + for var in sorted(independent_vars, key=len, reverse=True): if var in label_lower: return var return independent_vars[0] @staticmethod - def _infer_callable_depends_on(func, independent_vars, name): + def _infer_callable_depends_on(func, independent_vars, name, single_var=None): """Infer ``depends_on`` for a plain callable. - Two conventions are accepted, checked in order: + Conventions are accepted in order: + 0. *Single variable* - when ``single_var`` is given and the callable + takes a single argument, it depends on that one variable regardless + of the parameter name (e.g. a Mach-only drag ``lambda mach: ...``). 1. *Named subset* - every parameter name is an independent variable, so the parameters themselves name the dependency subset (e.g. ``lambda alpha, mach: ...``). @@ -189,6 +455,8 @@ def _infer_callable_depends_on(func, independent_vars, name): params = [] names = [p.name for p in params] + if single_var and len(names) == 1: + return [single_var] if names and set(names) <= set(independent_vars): return names if len(names) == n_vars: @@ -223,7 +491,50 @@ def get_value_opt(self, *args): # model grabs ``get_value_opt`` directly for the hot loop. __call__ = get_value_opt + def __mul__(self, other): + """Scale the coefficient by ``other``, returning a new AeroCoefficient. + + Used by the Monte Carlo drag factor (``coefficient *= factor``). The + underlying constant or :class:`Function` is scaled while ``depends_on``, + the independent-variable axes and ``extrapolation`` are preserved. + """ + source = self._constant if self._constant is not None else self.function + return AeroCoefficient( + source * other, + self.depends_on, + self.unsteady_aero, + self.control_variables, + self.name, + extrapolation=self.extrapolation, + ) + + __rmul__ = __mul__ + + def to_dict(self, **kwargs): # pylint: disable=unused-argument + """Serialize the coefficient for :class:`rocketpy._encoders.RocketPyEncoder`.""" + return { + "source": self._constant if self._constant is not None else self.function, + "depends_on": list(self.depends_on), + "unsteady_aero": self.unsteady_aero, + "control_variables": list(self.control_variables), + "name": self.name, + "extrapolation": self.extrapolation, + } + + @classmethod + def from_dict(cls, data): + """Rebuild an :class:`AeroCoefficient` from its :meth:`to_dict` form.""" + return cls( + data["source"], + data["depends_on"], + data.get("unsteady_aero", False), + data.get("control_variables", ()), + data["name"], + extrapolation=data.get("extrapolation"), + ) + def __repr__(self): + """Return a concise representation showing the constant or dependencies.""" if self._constant is not None: return f"AeroCoefficient({self.name}={self._constant})" return f"AeroCoefficient({self.name}, depends_on={self.depends_on})" diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index 3c4958255..221440dc6 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -120,7 +120,14 @@ def __init__( # ``deployment_level`` control axis. The deployment-0 ⇒ Cd 0 rule applies # only when the air brakes add to (rather than override) the rocket drag. def drag_coefficient_function( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate, deployment_level + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + deployment_level, ): # pylint: disable=unused-argument if deployment_level == 0 and not self.override_rocket_drag: return 0.0 diff --git a/rocketpy/rocket/aero_surface/controllable_generic_surface.py b/rocketpy/rocket/aero_surface/controllable_generic_surface.py index 42e473eb9..049ba6206 100644 --- a/rocketpy/rocket/aero_surface/controllable_generic_surface.py +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -1,9 +1,4 @@ -from rocketpy.plots.aero_surface_plots import _GenericSurfacePlots -from rocketpy.prints.aero_surface_prints import _GenericSurfacePrints -from rocketpy.rocket.aero_surface.generic_surface import ( - BASE_INDEPENDENT_VARS, - GenericSurface, -) +from rocketpy.rocket.aero_surface.generic_surface import GenericSurface class ControllableGenericSurface(GenericSurface): @@ -61,9 +56,9 @@ def __init__( """ # These must be set before ``super().__init__`` so coefficient # processing (arity, CSV validation) and the derived-cp accessors see - # the extended variable list and the current control values. + # the extended variable list (via the ``independent_vars`` property, + # which appends ``control_variables``) and the current control values. self.control_variables = list(controls) - self.independent_vars = BASE_INDEPENDENT_VARS + self.control_variables self.control_state = {name: 0.0 for name in self.control_variables} super().__init__( @@ -73,9 +68,7 @@ def __init__( center_of_pressure=center_of_pressure, name=name, ) - - self.prints = _GenericSurfacePrints(self) - self.plots = _GenericSurfacePlots(self) + # ``self.prints``/``self.plots`` are the generic ones wired by the base. def _coefficient_arguments( self, diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py index ac49d92e7..b2875e658 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -91,6 +91,12 @@ class Fin(_BaseFin): damping coefficient and the cant angle in radians. """ + # A single fin contributes unequally to the pitch and yaw planes + # (``cL_alpha`` ~ sin^2(phi), ``cQ_beta`` ~ cos^2(phi)), so it is not + # axisymmetric on its own. A complete, evenly spaced set may still be + # axisymmetric collectively, which the rocket's numeric check resolves. + is_axisymmetric = False + def __init__( self, angular_position, @@ -296,7 +302,12 @@ def evaluate_rotation_matrix(self): sin_delta = math.sin(delta) cos_delta = math.cos(delta) - # Rotation about body Z by angular position + # The body -> fin change of basis is composed right-to-left as + # ``R_delta @ R_phi @ R_pi`` (R_pi first, R_delta last). Each factor + # therefore acts on the coordinates produced by the factors to its right, + # i.e. in the *current* (partially rotated) frame, not the body frame. + + # Roll by the angular position, about the rocket longitudinal axis. R_phi = Matrix( [ [cos_phi, -sin_phi, 0], @@ -305,7 +316,12 @@ def evaluate_rotation_matrix(self): ] ) - # Cant rotation about body Y + # Cant rotation about the fin **span (y) axis**. Because R_delta is the + # leftmost factor, it acts on coordinates already in the rolled + # uncanted-fin frame, so it rotates about that frame's y axis (the fin's + # own root-to-tip direction) -- NOT body Y, with which it coincides only + # at angular_position = 0. This is what makes each fin cant about its own + # span; using body Y (``R_uncanted @ R_delta``) would be wrong. R_delta = Matrix( [ [cos_delta, 0, -sin_delta], @@ -314,7 +330,9 @@ def evaluate_rotation_matrix(self): ] ) - # 180 flip about Y to align fin leading/trailing edge + # 180 flip about Y so the uncanted fin z axis points leading -> trailing + # edge (toward the tail, i.e. -body z), with x completing a right-handed + # frame. Proper rotation (det +1), not a reflection. R_pi = Matrix( [ [-1, 0, 0], @@ -323,7 +341,7 @@ def evaluate_rotation_matrix(self): ] ) - # Uncanted body to fin, then apply cant + # Uncanted body -> fin, then apply the cant in the fin span frame. R_uncanted = R_phi @ R_pi R_body_to_fin = R_delta @ R_uncanted diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index 90c0a3537..d9f6fe82a 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,25 +1,16 @@ import copy -import csv import math import numpy as np from rocketpy.mathutils import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector -from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient - -# Single source of truth for the coefficient independent variables. Subclasses -# (e.g. ControllableGenericSurface, or the alpha_dot/beta_dot extension) append -# extra axes to this base via ``self.independent_vars``. -BASE_INDEPENDENT_VARS = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", -] +from rocketpy.plots.aero_surface_plots import _GenericSurfacePlots +from rocketpy.prints.aero_surface_prints import _GenericSurfacePrints +from rocketpy.rocket.aero_surface.aero_coefficient import ( + AeroCoefficient, + build_independent_vars, +) class GenericSurface: @@ -28,6 +19,14 @@ class GenericSurface: attack, sideslip angle, Mach number, Reynolds number, pitch rate, yaw rate and roll rate.""" + #: Whether this surface contributes identically to the pitch and yaw planes + #: *by construction*. Conservatively ``False`` for a generic surface (its + #: coefficients may differ between planes); the built-in axisymmetric + #: surfaces override it to ``True``. The rocket uses it to skip the numeric + #: pitch/yaw axisymmetry check when every surface is symmetric by + #: construction. + is_axisymmetric = False + def __init__( self, reference_area, @@ -50,6 +49,13 @@ def __init__( "reynolds", "pitch_rate", "yaw_rate" and "roll_rate". The independent variable columns can be provided in any order. + The angular-rate inputs ("pitch_rate", "yaw_rate", "roll_rate") are the + conventional **non-dimensional reduced rates**, ``q* = q * L_ref / (2 * V)`` + (and likewise for ``r``/``p``), matching how published and tool-generated + aerotables (Missile DATCOM, OpenVSP, CFD/wind-tunnel data) tabulate rate + derivatives. Provide coefficient tables against the reduced rates, not the + raw body rates in rad/s. + See Also -------- :ref:`genericsurfaces`. @@ -99,16 +105,24 @@ def __init__( unaffected. Default is False. """ - # Independent variables the coefficients depend on. Subclasses may set - # this (with extra axes appended) before calling ``super().__init__``. - # When ``unsteady_aero`` is enabled, the time-derivatives of the flow - # angles (``alpha_dot``, ``beta_dot``) are appended as extra axes - # (defaulting to 0 at runtime, so existing tables are unaffected). + # The independent variables of the coefficients are derived (see the + # ``independent_vars`` property) from ``unsteady_aero`` and, for + # subclasses, ``control_variables``. When ``unsteady_aero`` is enabled, + # the time-derivatives of the flow angles (``alpha_dot``, ``beta_dot``) + # become extra axes (defaulting to 0 at runtime, so existing tables are + # unaffected). Subclasses that add externally-supplied axes set + # ``control_variables`` before calling ``super().__init__``. self._unsteady_aero = unsteady_aero - if not hasattr(self, "independent_vars"): - self.independent_vars = list(BASE_INDEPENDENT_VARS) - if unsteady_aero: - self.independent_vars += ["alpha_dot", "beta_dot"] + # Externally-supplied axes (e.g. control deflections). Subclasses set + # this before ``super().__init__``; defaults to none for plain surfaces. + self.control_variables = getattr(self, "control_variables", ()) + # Ordered independent variables accepted by every coefficient: the seven + # base axes, plus ``alpha_dot``/``beta_dot`` when ``unsteady_aero`` is + # enabled (integrator-supplied), plus any ``control_variables`` a + # subclass appended (externally supplied). Fixed at construction. + self.independent_vars = build_independent_vars( + self._unsteady_aero, self.control_variables + ) self.reference_area = reference_area self.reference_length = reference_length @@ -125,12 +139,22 @@ def __init__( self._check_coefficients(coefficients, default_coefficients) coefficients = self._complete_coefficients(coefficients, default_coefficients) for coeff, coeff_value in coefficients.items(): - value = self._process_input(coeff_value, coeff) + value = AeroCoefficient( + coeff_value, + unsteady_aero=self._unsteady_aero, + control_variables=self.control_variables, + name=coeff, + ) setattr(self, coeff, value) self.evaluate_coefficients() self._evaluate_derived_coefficients() + # Reporting layers. Subclasses override these with their own (more + # specific) prints/plots after calling ``super().__init__``. + self.prints = _GenericSurfacePrints(self) + self.plots = _GenericSurfacePlots(self) + @property def force_application_point(self): """Local point (surface frame) at which the resultant force is applied @@ -142,6 +166,27 @@ def force_application_point(self): """ return Vector([self.cpx, self.cpy, self.cpz]) + def info(self): + """Prints a summary of the surface's geometry and aerodynamic + coefficients. Subclasses override this with surface-specific summaries. + + Returns + ------- + None + """ + self.prints.geometry() + self.prints.coefficients() + + def all_info(self): + """Prints and plots all available information of the surface. + + Returns + ------- + None + """ + self.prints.all() + self.plots.all() + def evaluate_coefficients(self): """Hook for subclasses to (re)populate the aerodynamic coefficient ``Function``s from their geometry. The base class builds coefficients @@ -202,10 +247,16 @@ def _set_derived_cp_accessors(self, cL_alpha, cm_alpha, cQ_beta, cn_beta): local_cpz = self.force_application_point[2] def _cp_z(force_slope, moment_slope): + # Recover the center of pressure from a force slope and its matching + # moment slope, as a Function of Mach. def cp_z(mach): slope = force_slope.get_value_opt(mach) + # No force at this Mach -> the cp is undefined; fall back to the + # geometric application point so this surface contributes zero + # weight to the force-weighted cp average. if slope == 0: return local_cpz + # cp = application point - (moment slope / force slope) * L_ref. return ( local_cpz - moment_slope.get_value_opt(mach) / slope * reference_length @@ -217,9 +268,9 @@ def cp_z(mach): self.lift_coefficient_derivative = cL_alpha self.center_of_pressure_z = _cp_z(cL_alpha, cm_alpha) - # Yaw plane. The side-force slope is sign-adjusted (``-cQ_beta``) so that - # an axisymmetric surface yields the same signed weight as the pitch - # plane, making the two planes' margins coincide when symmetric. + # Yaw plane. The side-force slope is sign-adjusted (``-cQ_beta``) so + # that an axisymmetric surface yields the same signed weight as the + # pitch plane, making the two planes' margins coincide when symmetric. self.side_coefficient_derivative = -cQ_beta self.center_of_pressure_z_yaw = _cp_z(cQ_beta, cn_beta) @@ -366,11 +417,11 @@ def _compute_from_coefficients( reynolds : float Reynolds number. pitch_rate : float - Pitch rate in radians per second. + Non-dimensional (reduced) pitch rate, ``q * L_ref / (2 * V)``. yaw_rate : float - Yaw rate in radians per second. + Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float - Roll rate in radians per second. + Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. Returns ------- @@ -497,6 +548,17 @@ def compute_forces_and_moments( alpha = np.arctan2(stream_velocity[1], stream_velocity[2]) beta = np.arctan2(stream_velocity[0], stream_velocity[2]) + # Non-dimensionalize the body angular rates into the conventional reduced + # rates (e.g. ``q* = q * L_ref / (2 * V)``) before evaluating the + # coefficients, so coefficient tables follow the standard aerotable + # convention (Missile DATCOM, OpenVSP, CFD/wind-tunnel data tabulate rate + # derivatives against the reduced rates). The factor is 0 at zero airspeed + # (pad/static) to avoid division by zero; there is no aerodynamic damping + # there anyway. + reduced_rate_factor = ( + self.reference_length / (2 * stream_speed) if stream_speed > 0 else 0.0 + ) + # Compute aerodynamic forces and moments lift, side, drag, pitch, yaw, roll = self._compute_from_coefficients( rho, @@ -505,9 +567,9 @@ def compute_forces_and_moments( beta, stream_mach, reynolds, - omega[0], # q - omega[1], # r - omega[2], # p + omega[0] * reduced_rate_factor, # q* reduced pitch rate + omega[1] * reduced_rate_factor, # r* reduced yaw rate + omega[2] * reduced_rate_factor, # p* reduced roll rate alpha_dot, beta_dot, ) @@ -515,11 +577,7 @@ def compute_forces_and_moments( # Conversion from the aerodynamic frame to the body frame. This is the # direction cosine matrix (DCM) that expresses the aerodynamic-frame # force components in the body frame, i.e. rotations by ``-alpha`` about - # x and ``+beta`` about y. Using the opposite-sign "vector rotation" - # matrices is incorrect: it leaves the result effectively in the - # aerodynamic frame, flipping the transverse components of any force that - # has a drag part (see RocketPy issue #932). Surfaces with no drag (the - # Barrowman lift/side surfaces) differ only in the small axial term. + # x and ``+beta`` about y. rotation_matrix = Matrix( [ [1, 0, 0], @@ -539,100 +597,3 @@ def compute_forces_and_moments( M1, M2, M3 = Vector([pitch, yaw, roll]) + (cp ^ Vector([R1, R2, R3])) return R1, R2, R3, M1, M2, M3 - - def _process_input(self, input_data, coeff_name): - """Process a coefficient input into an :class:`AeroCoefficient`. - - Accepts a number, a callable, a :class:`Function`, or a path to a CSV - file, storing the coefficient at its intrinsic dimensionality (its - ``depends_on``) rather than forcing it into a full - ``len(self.independent_vars)``-D ``Function``. See - :class:`AeroCoefficient`. - - Parameters - ---------- - input_data : int, float, str, callable, or Function - Input data to be processed. - coeff_name : str - Name of the coefficient being processed for error reporting. - - Returns - ------- - AeroCoefficient - Callable over the full ``self.independent_vars`` argument tuple. - """ - return AeroCoefficient.from_input( - input_data, - coeff_name, - self.independent_vars, - csv_loader=self.__load_generic_surface_csv, - ) - - def __load_generic_surface_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load a GenericSurface coefficient CSV at minimal dimension. - - This loader expects header-based CSV data with one or more independent - variables among ``self.independent_vars`` (the seven base variables, - plus any extra axes added by subclasses such as control deflections). - - Returns - ------- - tuple - ``(function, depends_on)`` where ``function`` is a low-dimensional - ``Function`` over the present columns and ``depends_on`` lists those - columns. Consumed by :meth:`AeroCoefficient.from_input`. - """ - independent_vars = list(self.independent_vars) - - try: - with open(file_path, mode="r") as file: - reader = csv.reader(file) - header = next(reader) - except (FileNotFoundError, IOError) as e: - raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e - except StopIteration as e: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e - - if not header: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") - - header = [column.strip() for column in header] - present_columns = [col for col in independent_vars if col in header] - - invalid_columns = [col for col in header[:-1] if col not in independent_vars] - if invalid_columns: - raise ValueError( - f"Invalid independent variable(s) in {coeff_name} CSV: " - f"{invalid_columns}. Valid options are: {independent_vars}." - ) - - if header[-1] in independent_vars: - raise ValueError( - f"Last column in {coeff_name} CSV must be the coefficient" - " value, not an independent variable." - ) - - if not present_columns: - raise ValueError(f"No independent variables found in {coeff_name} CSV.") - - ordered_present_columns = [ - col for col in header[:-1] if col in independent_vars - ] - - csv_func = Function.from_regular_grid_csv( - file_path, - ordered_present_columns, - coeff_name, - extrapolation="natural", - ) - if csv_func is None: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="natural", - ) - - # The CSV columns may appear in any order; AeroCoefficient maps the full - # argument tuple to ``ordered_present_columns`` order, so the stored - # Function is queried directly at its own (minimal) dimensionality. - return csv_func, ordered_present_columns diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index fa085252c..8bf2476ef 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -390,23 +390,6 @@ def compute_all_coefficients(self): ) self.cld = self.compute_damping_coefficient(self.cl_p, self.cl_q, self.cl_r) - self._expose_uniform_coefficients() - - def _expose_uniform_coefficients(self): - """Expose the main force/moment coefficients (``cL, cQ, cD, cm, cn``) as - the composed *forcing* coefficients, so every surface - including - Barrowman ones whose coefficients are derived from geometry - has - uniform, callable accessors over the standard argument tuple. - - The forcing coefficient is the static, flow-state part of the model - (``c_0 + c_alpha*alpha + c_beta*beta``); the rate-damping parts - (``cLd``, …) are dimensionally tied to the reduced rate and remain - separate. The roll coefficient is intentionally **not** exposed as - ``cl`` here: geometry-defined subclasses (nose cones, tails, individual - fins) use the legacy ``cl`` name for their *lift* coefficient. The - composed roll forcing/damping remain available as ``clf``/``cld``. - """ - # pylint: disable=invalid-name self.cL = self.cLf self.cQ = self.cQf self.cD = self.cDf @@ -448,11 +431,11 @@ def _compute_from_coefficients( reynolds : float Reynolds number. pitch_rate : float - Pitch rate in radians per second. + Non-dimensional (reduced) pitch rate, ``q * L_ref / (2 * V)``. yaw_rate : float - Yaw rate in radians per second. + Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float - Roll rate in radians per second. + Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. Returns ------- @@ -460,20 +443,14 @@ def _compute_from_coefficients( The aerodynamic forces (lift, side_force, drag) and moments (pitch, yaw, roll) in the body frame. """ - # Precompute common values + # Precompute common values. The angular rates arrive already + # non-dimensionalized (reduced rates, e.g. ``q* = q * L_ref / (2 * V)``), + # so the rate-damping terms use the same dynamic-pressure scaling as the + # forcing terms: the ``L_ref / (2 * V)`` factor now lives in the rate + # itself, not in the scaling. (Algebraically identical to the previous + # ``0.5 * rho * V * A * L / 2`` damping scaling applied to raw rates.) dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area - dyn_pressure_area_damping = ( - 0.5 * rho * stream_speed * self.reference_area * self.reference_length / 2 - ) dyn_pressure_area_length = dyn_pressure_area * self.reference_length - dyn_pressure_area_length_damping = ( - 0.5 - * rho - * stream_speed - * self.reference_area - * self.reference_length**2 - / 2 - ) # Evaluate the composed coefficients through the fast, unvalidated # ``get_value_opt`` path (the composed coefficients are callable-source @@ -481,30 +458,26 @@ def _compute_from_coefficients( # ``__call__``/``get_value`` argument validation in the hot loop). args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - # Compute aerodynamic forces - lift = dyn_pressure_area * self.cLf.get_value_opt( - *args - ) + dyn_pressure_area_damping * self.cLd.get_value_opt(*args) - - side = dyn_pressure_area * self.cQf.get_value_opt( - *args - ) + dyn_pressure_area_damping * self.cQd.get_value_opt(*args) - - drag = dyn_pressure_area * self.cDf.get_value_opt( - *args - ) + dyn_pressure_area_damping * self.cDd.get_value_opt(*args) - - # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cmf.get_value_opt( - *args - ) + dyn_pressure_area_length_damping * self.cmd.get_value_opt(*args) - - yaw = dyn_pressure_area_length * self.cnf.get_value_opt( - *args - ) + dyn_pressure_area_length_damping * self.cnd.get_value_opt(*args) + # Compute aerodynamic forces (forcing + reduced-rate damping) + lift = dyn_pressure_area * ( + self.cLf.get_value_opt(*args) + self.cLd.get_value_opt(*args) + ) + side = dyn_pressure_area * ( + self.cQf.get_value_opt(*args) + self.cQd.get_value_opt(*args) + ) + drag = dyn_pressure_area * ( + self.cDf.get_value_opt(*args) + self.cDd.get_value_opt(*args) + ) - roll = dyn_pressure_area_length * self.clf.get_value_opt( - *args - ) + dyn_pressure_area_length_damping * self.cld.get_value_opt(*args) + # Compute aerodynamic moments (forcing + reduced-rate damping) + pitch = dyn_pressure_area_length * ( + self.cmf.get_value_opt(*args) + self.cmd.get_value_opt(*args) + ) + yaw = dyn_pressure_area_length * ( + self.cnf.get_value_opt(*args) + self.cnd.get_value_opt(*args) + ) + roll = dyn_pressure_area_length * ( + self.clf.get_value_opt(*args) + self.cld.get_value_opt(*args) + ) return lift, side, drag, pitch, yaw, roll diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index 19ad16f32..a6fc75b56 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -27,6 +27,10 @@ class RailButtons(GenericSurface): calculated but flight dynamics remain unaffected. """ + # Rail buttons carry no aerodynamic force, so they contribute nothing to + # either plane: axisymmetric for the pitch/yaw check. + is_axisymmetric = True + def __init__( self, buttons_distance, diff --git a/rocketpy/rocket/point_mass_rocket.py b/rocketpy/rocket/point_mass_rocket.py index 5965a9c72..dc198c41f 100644 --- a/rocketpy/rocket/point_mass_rocket.py +++ b/rocketpy/rocket/point_mass_rocket.py @@ -57,11 +57,11 @@ class PointMassRocket(Rocket): power_on_drag_input : int, float, callable, array, string, Function Original user input for the drag coefficient with motor on. Preserved for reconstruction and Monte Carlo workflows. - power_off_drag_7d : Function - Drag coefficient function with seven inputs in the order: + power_off_drag_7d : AeroCoefficient + Drag coefficient callable over seven independent variables in the order: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. - power_on_drag_7d : Function - Drag coefficient function with seven inputs in the order: + power_on_drag_7d : AeroCoefficient + Drag coefficient callable over seven independent variables in the order: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. power_off_drag_by_mach : Function Convenience wrapper for power-off drag as a Mach-only function. diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 2092e89e6..f8b4fc0f1 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,4 +1,3 @@ -import csv import inspect import math import warnings @@ -21,6 +20,7 @@ Tail, TrapezoidalFins, ) +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient from rocketpy.rocket.aero_surface.fins.elliptical_fin import EllipticalFin from rocketpy.rocket.aero_surface.fins.free_form_fin import FreeFormFin from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins @@ -165,12 +165,14 @@ class Rocket: Rocket.power_on_drag_input : int, float, callable, string, array, Function Original user input for rocket's drag coefficient when the motor is on. Preserved for reconstruction and Monte Carlo workflows. - Rocket.power_off_drag_7d : Function - Rocket's drag coefficient with motor off as a 7D function of - (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate). - Rocket.power_on_drag_7d : Function - Rocket's drag coefficient with motor on as a 7D function of - (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate). + Rocket.power_off_drag_7d : AeroCoefficient + Rocket's drag coefficient with motor off, callable over the seven + independent variables (alpha, beta, mach, reynolds, pitch_rate, + yaw_rate, roll_rate) and stored at its intrinsic dimensionality. + Rocket.power_on_drag_7d : AeroCoefficient + Rocket's drag coefficient with motor on, callable over the seven + independent variables (alpha, beta, mach, reynolds, pitch_rate, + yaw_rate, roll_rate) and stored at its intrinsic dimensionality. Rocket.power_off_drag_by_mach : Function Rocket's drag coefficient with motor off as a function of Mach number. Rocket.power_on_drag_by_mach : Function @@ -348,20 +350,20 @@ def __init__( # pylint: disable=too-many-statements self.surfaces_cp_to_cdm = {} self.rail_buttons = Components() - self.aerodynamic_center = Function( + self._aerodynamic_center = Function( lambda mach: 0, inputs="Mach Number", outputs="Aerodynamic Center Position (m)", ) - self.total_lift_coeff_der = Function( + self._total_lift_coeff_der = Function( lambda mach: 0, inputs="Mach Number", outputs="Total Lift Coefficient Derivative", ) - self.static_margin = Function( + self._static_margin = Function( lambda time: 0, inputs="Time (s)", outputs="Static Margin (c)" ) - self.stability_margin = Function( + self._stability_margin = Function( lambda mach, time: 0, inputs=["Mach", "Time (s)"], outputs="Stability Margin (c)", @@ -369,32 +371,40 @@ def __init__( # pylint: disable=too-many-statements # Yaw-plane counterparts. The pitch-plane attributes above remain the # primary (default) margin; these expose the yaw plane for # non-axisymmetric rockets (see ``evaluate_center_of_pressure``). - self.aerodynamic_center_yaw = Function( + self._aerodynamic_center_yaw = Function( lambda mach: 0, inputs="Mach Number", outputs="Aerodynamic Center Position - Yaw (m)", ) - self.total_side_coeff_der = Function( + self._total_side_coeff_der = Function( lambda mach: 0, inputs="Mach Number", outputs="Total Side Coefficient Derivative", ) - self.static_margin_yaw = Function( + self._static_margin_yaw = Function( lambda time: 0, inputs="Time (s)", outputs="Static Margin - Yaw (c)" ) - self.stability_margin_yaw = Function( + self._stability_margin_yaw = Function( lambda mach, time: 0, inputs=["Mach", "Time (s)"], outputs="Stability Margin - Yaw (c)", ) - # Define aerodynamic drag coefficients - # Coefficients used during flight simulation - self.power_off_drag_7d = self.__process_drag_input( - power_off_drag, "Drag Coefficient with Power Off" + # Define aerodynamic drag coefficients used during flight simulation. + # Drag is stored at its intrinsic dimensionality over the seven base + # independent variables; 1-D inputs are taken as Mach, and "constant" + # extrapolation is used (drag should not extrapolate beyond its range). + self.power_off_drag_7d = AeroCoefficient( + power_off_drag, + name="Drag Coefficient with Power Off", + extrapolation="constant", + single_var="mach", ) - self.power_on_drag_7d = self.__process_drag_input( - power_on_drag, "Drag Coefficient with Power On" + self.power_on_drag_7d = AeroCoefficient( + power_on_drag, + name="Drag Coefficient with Power On", + extrapolation="constant", + single_var="mach", ) self.power_on_drag_by_mach = Function( lambda mach: self.power_on_drag_7d(0, 0, mach, 0, 0, 0, 0), @@ -436,10 +446,12 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - # Evaluate stability (even though no aerodynamic surfaces are present yet) - self.evaluate_center_of_pressure() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # The aerodynamic center and the margins are evaluated lazily (see the + # ``aerodynamic_center`` / ``static_margin`` properties); just flag them + # outdated here. They are rebuilt on first access, once all surfaces and + # the motor have been added. + self._cp_outdated = True + self._margin_outdated = True # Initialize plots and prints object self.prints = _RocketPrints(self) @@ -633,6 +645,80 @@ def evaluate_thrust_to_weight(self): self.thrust_to_weight.set_outputs("Thrust/Weight") self.thrust_to_weight.set_title("Thrust to Weight ratio") + # Lazily-evaluated aerodynamic outputs. + # + # The pitch/yaw aerodynamic centers and the static/stability margins are + # *derived* from the aerodynamic surfaces (and, for the margins, the center + # of mass). Rather than recompute them eagerly on every ``add_*`` call - an + # O(N^2) cost while building, repeated for every rocket in a Monte Carlo run - + # the mutating methods only flag them outdated; the value is rebuilt on first + # access and cached until the next change. ``_cp_outdated`` tracks the + # surface-dependent centers; ``_margin_outdated`` additionally tracks the + # center of mass, so adding a motor refreshes the margins without recomputing + # the surface-only aerodynamic center. + + def _ensure_aerodynamic_center(self): + """Recompute the pitch/yaw aerodynamic centers if a surface changed.""" + if self._cp_outdated: + self.evaluate_center_of_pressure() # clears ``_cp_outdated`` + + def _ensure_margins(self): + """Recompute the static/stability margins if a surface or the center of + mass changed. The underlying aerodynamic center is refreshed lazily by + the margin source closures.""" + if self._margin_outdated: + self._margin_outdated = False + self.evaluate_stability_margin() + self.evaluate_static_margin() + + @property + def aerodynamic_center(self): + """Pitch-plane aerodynamic center vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._aerodynamic_center + + @property + def aerodynamic_center_yaw(self): + """Yaw-plane aerodynamic center vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._aerodynamic_center_yaw + + @property + def total_lift_coeff_der(self): + """Total normal-force-coefficient derivative vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._total_lift_coeff_der + + @property + def total_side_coeff_der(self): + """Total side-force-coefficient derivative vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._total_side_coeff_der + + @property + def static_margin(self): + """Pitch-plane static margin (calibers) vs time (lazily evaluated).""" + self._ensure_margins() + return self._static_margin + + @property + def static_margin_yaw(self): + """Yaw-plane static margin (calibers) vs time (lazily evaluated).""" + self._ensure_margins() + return self._static_margin_yaw + + @property + def stability_margin(self): + """Pitch-plane stability margin (calibers) vs Mach and time (lazy).""" + self._ensure_margins() + return self._stability_margin + + @property + def stability_margin_yaw(self): + """Yaw-plane stability margin (calibers) vs Mach and time (lazy).""" + self._ensure_margins() + return self._stability_margin_yaw + def evaluate_center_of_pressure(self): """Evaluates the rocket's **aerodynamic center** as a function of Mach number, relative to the user-defined rocket reference system. @@ -661,11 +747,17 @@ def evaluate_center_of_pressure(self): reference system. See :doc:`Positions and Coordinate Systems ` for more information. """ + # Mark the pitch/yaw centers up to date before computing, so that a read + # of the ``aerodynamic_center`` property during this method (the + # ``is_axisymmetric`` check below) returns the value being built here + # rather than recursing back into this method. + self._cp_outdated = False + # Re-Initialize total force coefficient derivatives and AC positions - self.total_lift_coeff_der.set_source(lambda mach: 0) - self.aerodynamic_center.set_source(lambda mach: 0) - self.total_side_coeff_der.set_source(lambda mach: 0) - self.aerodynamic_center_yaw.set_source(lambda mach: 0) + self._total_lift_coeff_der.set_source(lambda mach: 0) + self._aerodynamic_center.set_source(lambda mach: 0) + self._total_side_coeff_der.set_source(lambda mach: 0) + self._aerodynamic_center_yaw.set_source(lambda mach: 0) # Calculate total force coefficient derivatives and aerodynamic center if len(self.aerodynamic_surfaces) > 0: @@ -674,31 +766,47 @@ def evaluate_center_of_pressure(self): cp_z = aero_surface.center_of_pressure_z # ref_factor corrects force for different reference areas ref_factor = aero_surface.reference_area / self.area - self.total_lift_coeff_der += ref_factor * lift_coeff_der - self.aerodynamic_center += ( + self._total_lift_coeff_der += ref_factor * lift_coeff_der + self._aerodynamic_center += ( ref_factor * lift_coeff_der * (position.z - self._csys * cp_z) ) # Yaw plane. side_coeff_der = aero_surface.side_coefficient_derivative cp_z_yaw = aero_surface.center_of_pressure_z_yaw - self.total_side_coeff_der += ref_factor * side_coeff_der - self.aerodynamic_center_yaw += ( + self._total_side_coeff_der += ref_factor * side_coeff_der + self._aerodynamic_center_yaw += ( ref_factor * side_coeff_der * (position.z - self._csys * cp_z_yaw) ) # Avoid errors when only zero-lift surfaces are added - if self.total_lift_coeff_der.get_value(0) != 0: - self.aerodynamic_center /= self.total_lift_coeff_der - if self.total_side_coeff_der.get_value(0) != 0: - self.aerodynamic_center_yaw /= self.total_side_coeff_der + if self._total_lift_coeff_der.get_value(0) != 0: + self._aerodynamic_center /= self._total_lift_coeff_der + if self._total_side_coeff_der.get_value(0) != 0: + self._aerodynamic_center_yaw /= self._total_side_coeff_der - self._warn_if_asymmetric_cp() - return self.aerodynamic_center + return self._aerodynamic_center def _cp_plane_max_difference(self): - """Largest pitch- vs yaw-plane aerodynamic center difference, in meters, - over a few sample Mach numbers.""" - sample_machs = (0.0, 0.5, 1.0) + """Largest pitch- vs yaw-plane aerodynamic center difference, in meters. + + The difference is sampled densely across the subsonic, transonic and + supersonic regimes rather than at a few fixed Mach numbers. A + non-axisymmetric configuration (only possible through a ``GenericSurface`` + with non-mirror coefficients) can have its pitch/yaw aerodynamic centers + diverge in any Mach range, and the difference can vanish at isolated Mach + numbers; sparse fixed sampling (e.g. only 0, 0.5, 1) risks a *false + negative* -- silently reporting an asymmetric rocket as axisymmetric, so + the user trusts the pitch-plane-only static margin. The built-in + (Barrowman) surfaces are symmetric by construction, so this returns + exactly 0 for them at every Mach (no false positives). This runs once at + setup, not in the integration loop, so a dense sweep is cheap. + """ + # 0 to 3 in 0.2 steps covers RocketPy's flight regimes (sub/trans/ + # supersonic) with enough resolution that a real asymmetry, which spans a + # Mach *range*, cannot fall entirely between sample points. This only + # runs for rockets that contain a generic surface or individual fin (see + # the by-construction short-circuit in ``is_axisymmetric``). + sample_machs = np.linspace(0.0, 3.0, 16) return max( abs( self.aerodynamic_center.get_value_opt(mach) @@ -715,9 +823,38 @@ def is_axisymmetric(self): ``stability_margin`` describe the PITCH plane only and differ from their ``*_yaw`` counterparts (``aerodynamic_center_yaw``, ``static_margin_yaw``, ``stability_margin_yaw``).""" + # Fast path: the built-in nose, tail and fin sets contribute identically + # to the pitch and yaw planes by construction, so a rocket made only of + # surfaces that are axisymmetric-by-construction is axisymmetric without + # evaluating anything. Only a generic surface or an individual fin can + # break it, in which case fall back to the numeric Mach sweep below. + if all( + getattr(surface, "is_axisymmetric", False) + for surface, _ in self.aerodynamic_surfaces + ): + return True # Tolerance relative to the rocket diameter (caliber-scale). return self._cp_plane_max_difference() <= 1e-6 * (2 * self.radius) + def _warn_if_not_axisymmetric(self): + """Warn, at surface-add time, when the rocket is non-axisymmetric so the + user knows the scalar ``static_margin``/``stability_margin`` describe the + pitch plane only. Short-circuits with no computation for rockets built + solely from axisymmetric-by-construction surfaces (the Barrowman set); + only a generic surface or individual fin triggers the Mach sweep.""" + if not self.aerodynamic_surfaces or self.is_axisymmetric: + return + max_diff = self._cp_plane_max_difference() + warnings.warn( + "Pitch- and yaw-plane aerodynamic centers differ " + f"(max difference ~{max_diff:.4g} m): the rocket is not " + "axisymmetric. 'aerodynamic_center', 'static_margin' and " + "'stability_margin' describe the PITCH plane; use " + "'aerodynamic_center_yaw', 'static_margin_yaw' and " + "'stability_margin_yaw' for the yaw plane.", + stacklevel=3, + ) + @property def cp_position(self): """Deprecated alias for :attr:`aerodynamic_center` (the linearized, @@ -732,34 +869,6 @@ def cp_position(self): ) return self.aerodynamic_center - @property - def cp_position_yaw(self): - """Deprecated alias for :attr:`aerodynamic_center_yaw`.""" - warnings.warn( - "'cp_position_yaw' is deprecated and will be removed in a future " - "release; use 'aerodynamic_center_yaw' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.aerodynamic_center_yaw - - def _warn_if_asymmetric_cp(self): - """Warn when the pitch- and yaw-plane aerodynamic centers disagree, i.e. - the rocket is not axisymmetric. The ``static_margin``/ - ``stability_margin`` attributes then describe the pitch plane only; the - yaw-plane counterparts are ``*_yaw``.""" - if not self.is_axisymmetric: - max_diff = self._cp_plane_max_difference() - warnings.warn( - "Pitch- and yaw-plane aerodynamic centers differ " - f"(max difference ~{max_diff:.4g} m): the rocket is not " - "axisymmetric. 'aerodynamic_center', 'static_margin' and " - "'stability_margin' describe the PITCH plane; use " - "'aerodynamic_center_yaw', 'static_margin_yaw' and " - "'stability_margin_yaw' for the yaw plane.", - stacklevel=2, - ) - def _aerodynamic_center_limit(self, alpha, beta, mach): """Small-incidence limit of :meth:`center_of_pressure`: the linearized aerodynamic center, blended between the pitch and yaw planes by the @@ -872,8 +981,15 @@ def _aerodynamic_forces_and_moments(self, alpha, beta, mach, reynolds=0.0): for surface, _ in self.aerodynamic_surfaces: cp = self.surfaces_cp_to_cdm[surface] forces = surface.compute_forces_and_moments( - stream_velocity, stream_speed, mach, 1.0, cp, omega, - density, dynamic_viscosity, 0.0, + stream_velocity, + stream_speed, + mach, + 1.0, + cp, + omega, + density, + dynamic_viscosity, + 0.0, ) totals = [acc + value for acc, value in zip(totals, forces)] return (*totals, stream_speed) @@ -970,156 +1086,6 @@ def aerodynamic_coefficients_full(self, alpha, beta, mach, reynolds=0.0): "cl": m3 / dynamic_pressure_area_length, } - def center_of_pressure_over_alpha(self, mach=0.0, beta=0.0, reynolds=0.0): - """Center of pressure position as a Function of angle of attack. - - Convenience wrapper around :meth:`center_of_pressure` that fixes the - Mach number, sideslip and Reynolds number and exposes the center of - pressure travel with angle of attack as a plottable :class:`Function`. - - Parameters - ---------- - mach : float, optional - Free-stream Mach number. Default 0. - beta : float, optional - Sideslip angle, in radians. Default 0. - reynolds : float, optional - Rocket-level Reynolds number. Default 0. - - Returns - ------- - Function - Center of pressure position (m) versus angle of attack (rad). - """ - return Function( - lambda alpha: self.center_of_pressure(alpha, beta, mach, reynolds), - inputs="Angle of Attack (rad)", - outputs="Center of Pressure Position (m)", - title="Center of Pressure vs Angle of Attack", - ) - - def stability_margin_over_alpha( - self, mach=0.0, beta=0.0, reynolds=0.0, time=0.0 - ): - """Stability margin in calibers as a Function of angle of attack. - - The center-of-gravity-to-center-of-pressure distance divided by the - rocket diameter, using the nonlinear :meth:`center_of_pressure` so the - margin reflects how the center of pressure moves with incidence. This is - the angle-of-attack analogue of :attr:`static_margin` (which is the - ``alpha = 0`` value as a function of time). - - Parameters - ---------- - mach : float, optional - Free-stream Mach number. Default 0. - beta : float, optional - Sideslip angle, in radians. Default 0. - reynolds : float, optional - Rocket-level Reynolds number. Default 0. - time : float, optional - Time at which the center of mass is evaluated, in seconds. Default 0 - (the fully loaded condition). - - Returns - ------- - Function - Stability margin (calibers) versus angle of attack (rad). - """ - center_of_pressure = self.center_of_pressure_over_alpha(mach, beta, reynolds) - center_of_mass = self.center_of_mass.get_value_opt(time) - diameter = 2 * self.radius - return Function( - lambda alpha: ( - (center_of_mass - center_of_pressure.get_value_opt(alpha)) - / diameter - * self._csys - ), - inputs="Angle of Attack (rad)", - outputs="Stability Margin (c)", - title="Stability Margin vs Angle of Attack", - ) - - def center_of_pressure_over_beta(self, mach=0.0, alpha=0.0, reynolds=0.0): - """Center of pressure position as a Function of sideslip angle. - - Yaw-plane companion to :meth:`center_of_pressure_over_alpha`: fixes the - Mach number, angle of attack and Reynolds number and exposes the center - of pressure travel with sideslip as a plottable :class:`Function`. - - Parameters - ---------- - mach : float, optional - Free-stream Mach number. Default 0. - alpha : float, optional - Angle of attack, in radians. Default 0. - reynolds : float, optional - Rocket-level Reynolds number. Default 0. - - Returns - ------- - Function - Center of pressure position (m) versus sideslip angle (rad). - """ - def _cp(beta): - # At the exact origin the CP is a 0/0 limit; the general - # center_of_pressure resolves it to the PITCH-plane aerodynamic - # center (consistent with static_margin). For a pure-sideslip sweep - # the correct limit is instead the YAW-plane aerodynamic center, - # which the nonlinear value converges to as beta grows -- use it at - # beta = 0 so the sweep stays continuous. - if alpha == 0.0 and beta == 0.0: - return self.aerodynamic_center_yaw.get_value_opt(mach) - return self.center_of_pressure(alpha, beta, mach, reynolds) - - return Function( - _cp, - inputs="Sideslip Angle (rad)", - outputs="Center of Pressure Position (m)", - title="Center of Pressure vs Sideslip Angle", - ) - - def stability_margin_over_beta( - self, mach=0.0, alpha=0.0, reynolds=0.0, time=0.0 - ): - """Stability margin in calibers as a Function of sideslip angle. - - Yaw-plane companion to :meth:`stability_margin_over_alpha`: the - center-of-gravity-to-center-of-pressure distance divided by the rocket - diameter, using the nonlinear :meth:`center_of_pressure` so the margin - reflects how the center of pressure moves with sideslip. - - Parameters - ---------- - mach : float, optional - Free-stream Mach number. Default 0. - alpha : float, optional - Angle of attack, in radians. Default 0. - reynolds : float, optional - Rocket-level Reynolds number. Default 0. - time : float, optional - Time at which the center of mass is evaluated, in seconds. Default 0 - (the fully loaded condition). - - Returns - ------- - Function - Stability margin (calibers) versus sideslip angle (rad). - """ - center_of_pressure = self.center_of_pressure_over_beta(mach, alpha, reynolds) - center_of_mass = self.center_of_mass.get_value_opt(time) - diameter = 2 * self.radius - return Function( - lambda beta: ( - (center_of_mass - center_of_pressure.get_value_opt(beta)) - / diameter - * self._csys - ), - inputs="Sideslip Angle (rad)", - outputs="Stability Margin (c)", - title="Stability Margin vs Sideslip Angle", - ) - def evaluate_surfaces_cp_to_cdm(self): """Calculates the relative position of each aerodynamic surface center of pressure to the rocket's center of dry mass in Body Axes Coordinate @@ -1173,7 +1139,7 @@ def evaluate_stability_margin(self): the center of pressure and the center of mass, divided by the rocket's diameter. """ - self.stability_margin.set_source( + self._stability_margin.set_source( lambda mach, time: ( ( ( @@ -1186,7 +1152,7 @@ def evaluate_stability_margin(self): ) ) # Yaw-plane stability margin (equal to the pitch plane when axisymmetric) - self.stability_margin_yaw.set_source( + self._stability_margin_yaw.set_source( lambda mach, time: ( ( ( @@ -1198,7 +1164,7 @@ def evaluate_stability_margin(self): * self._csys ) ) - return self.stability_margin + return self._stability_margin def evaluate_static_margin(self): """Calculates the static margin of the rocket as a function of time. @@ -1211,7 +1177,7 @@ def evaluate_static_margin(self): pressure and the center of mass, divided by the rocket's diameter. """ # Calculate static margin - self.static_margin.set_source( + self._static_margin.set_source( lambda time: ( ( self.center_of_mass.get_value_opt(time) @@ -1221,16 +1187,16 @@ def evaluate_static_margin(self): ) ) # Change sign if coordinate system is upside down - self.static_margin *= self._csys - self.static_margin.set_inputs("Time (s)") - self.static_margin.set_outputs("Static Margin (c)") - self.static_margin.set_title("Static Margin") - self.static_margin.set_discrete( + self._static_margin *= self._csys + self._static_margin.set_inputs("Time (s)") + self._static_margin.set_outputs("Static Margin (c)") + self._static_margin.set_title("Static Margin") + self._static_margin.set_discrete( lower=0, upper=self.motor.burn_out_time, samples=200 ) # Yaw-plane static margin (equal to the pitch plane when axisymmetric) - self.static_margin_yaw.set_source( + self._static_margin_yaw.set_source( lambda time: ( ( self.center_of_mass.get_value_opt(time) @@ -1239,14 +1205,14 @@ def evaluate_static_margin(self): / (2 * self.radius) ) ) - self.static_margin_yaw *= self._csys - self.static_margin_yaw.set_inputs("Time (s)") - self.static_margin_yaw.set_outputs("Static Margin - Yaw (c)") - self.static_margin_yaw.set_title("Static Margin - Yaw") - self.static_margin_yaw.set_discrete( + self._static_margin_yaw *= self._csys + self._static_margin_yaw.set_inputs("Time (s)") + self._static_margin_yaw.set_outputs("Static Margin - Yaw (c)") + self._static_margin_yaw.set_title("Static Margin - Yaw") + self._static_margin_yaw.set_discrete( lower=0, upper=self.motor.burn_out_time, samples=200 ) - return self.static_margin + return self._static_margin def evaluate_dry_inertias(self): """Calculates and returns the rocket's dry inertias relative to @@ -1576,10 +1542,10 @@ def add_motor(self, motor, position): # pylint: disable=too-many-statements self.evaluate_inertias() self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - self.evaluate_center_of_pressure() self.evaluate_surfaces_cp_to_cdm() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # The motor changes the center of mass (and thus the margins) but not the + # surface-only aerodynamic center; flag only the margins for lazy rebuild. + self._margin_outdated = True self.evaluate_com_to_cdm_function() self.evaluate_nozzle_gyration_tensor() @@ -1658,9 +1624,15 @@ def add_surfaces(self, surfaces, positions): else: self.__add_single_surface(surfaces, positions) - self.evaluate_center_of_pressure() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # Adding a surface changes both the aerodynamic center and the margins; + # flag them for lazy rebuild on next access (see the properties). + self._cp_outdated = True + self._margin_outdated = True + + # The asymmetry warning is the one piece evaluated eagerly: it is a + # setup-time hint about which margin attributes to trust, and the check + # is free for axisymmetric-by-construction (Barrowman) rockets. + self._warn_if_not_axisymmetric() def add_vehicle_aerodynamic_surface( self, coefficients, reference_position=None, name="Vehicle Aerodynamics" @@ -2556,73 +2528,6 @@ def controller_wrapper(**kwargs): else: return air_brakes - def add_controllable_surface( - self, - surface, - position, - controller_function, - sampling_rate, - controlled_object_name="controllable_surface", - context=None, - name="Controller", - controller_needs=None, - return_controller=False, - ): - """Add a controllable aerodynamic surface and the controller that drives - its deflection during flight. - - The surface is added like any other aerodynamic surface (so it flows - through the standard per-surface force/moment computation), and a - controller is registered to mutate the surface's control variables each - sample. The controller function should set the surface's deflection via - ``surface.set_control(name, value)``. - - Parameters - ---------- - surface : ControllableGenericSurface - The controllable surface to add. - position : int, float, tuple, list, Vector - Position of the surface, in the same convention as - :meth:`add_surfaces`. - controller_function : callable - Control logic, ``controller_function(**kwargs) -> dict or None``. - See :class:`rocketpy.control.controller._Controller` for the - available ``kwargs``. The controlled surface is exposed under - ``controlled_object_name``. - sampling_rate : float - Controller sampling rate in hertz. - controlled_object_name : str, optional - Friendly name under which the surface is exposed in the controller - ``kwargs``. Default ``"controllable_surface"``. - context : dict, optional - Initial persistent controller context. Default ``None``. - name : str, optional - Controller name. Default ``"Controller"``. - controller_needs : list or frozenset of str or None, optional - Expensive simulation values the controller accesses. - return_controller : bool, optional - If True, also return the created controller. Default False. - - Returns - ------- - ControllableGenericSurface or tuple - The surface, or ``(surface, controller)`` if ``return_controller``. - """ - self.add_surfaces(surface, position) - controller = _Controller( - controller_function=controller_function, - controlled_objects=surface, - controlled_objects_name=controlled_object_name, - sampling_rate=sampling_rate, - context=context if context is not None else {}, - name=name, - controller_needs=controller_needs, - ) - self._add_controllers(controller) - if return_controller: - return surface, controller - return surface - def set_rail_buttons( self, upper_button_position, @@ -3006,271 +2911,3 @@ def from_dict(cls, data): rocket._add_controllers(controller) return rocket - - def __process_drag_input(self, input_data, coeff_name): - """Process drag coefficient input and normalize it to a 7D Function. - - Parameters - ---------- - input_data : int, float, str, callable, Function - Input data to be processed. - coeff_name : str - Name of the coefficient being processed for error reporting. - - Returns - ------- - Function - Function object with 7 input arguments in the following order: - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. - """ - inputs = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - # Helper: lift a 1D Mach-only source into the required 7D signature. - def _wrap_mach_only_source(mach_source): - return Function( - lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: ( - mach_source(mach) - ), - inputs, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - # Helper: enforce that Function-based inputs are either 1D (Mach) or 7D. - def _validate_function_domain_dimension(function): - if function.__dom_dim__ not in (1, 7): - raise ValueError( - f"{coeff_name} function must have either 1 input argument " - "(mach) or 7 input arguments (alpha, beta, mach, reynolds, " - "pitch_rate, yaw_rate, roll_rate), in that order." - ) - - # Helper: count required positional arguments in a callable. - def _count_positional_args(callable_obj): - signature = inspect.signature(callable_obj) - positional_params = [ - parameter - for parameter in signature.parameters.values() - if parameter.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - and parameter.default is inspect.Parameter.empty - ] - return len(positional_params) - - # Case 1: string input can be a CSV path or any Function-supported source. - if isinstance(input_data, str): - if input_data.lower().endswith(".csv"): - return self.__load_rocket_drag_csv(input_data, coeff_name) - - function_data = Function(input_data) - _validate_function_domain_dimension(function_data) - if function_data.__dom_dim__ == 7: - function_data.set_extrapolation("constant") - return function_data - return _wrap_mach_only_source(function_data.get_value_opt) - - # Case 2: Function input is accepted directly after domain validation. - if isinstance(input_data, Function): - _validate_function_domain_dimension(input_data) - if input_data.__dom_dim__ == 7: - input_data.set_extrapolation("constant") - return input_data - return _wrap_mach_only_source(input_data.get_value_opt) - - # Case 3: callable input must expose either 1 (Mach) or 7 arguments. - if callable(input_data): - n_positional_args = _count_positional_args(input_data) - if n_positional_args not in (1, 7): - raise ValueError( - f"{coeff_name} callable must have either 1 positional " - "argument (mach) or 7 positional arguments (alpha, beta, " - "mach, reynolds, pitch_rate, yaw_rate, roll_rate), in that " - "order." - ) - - if n_positional_args == 1: - return _wrap_mach_only_source(input_data) - - return Function( - input_data, - inputs, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - # Case 4: scalar input means a constant drag coefficient in all conditions. - if isinstance(input_data, (int, float)): - return Function( - lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: ( - float(input_data) - ), - inputs, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - # If is list/tuple try to pass it to a function. - # If composed of lists/tuples len 2, then interpret as function of mach - # Otherwise interpret it as function of all 7 variables - # This reuses Function's parser and then feeds back into this same pipeline. - if isinstance(input_data, (list, tuple)): - if all( - isinstance(item, (list, tuple)) and (len(item) == 2 or len(item) == 8) - for item in input_data - ): - try: - return self.__process_drag_input( - Function(list(input_data)), coeff_name - ) - except (TypeError, ValueError) as e: - raise ValueError( - f"Invalid list/tuple format for {coeff_name}. Expected " - "a list of [mach, coefficient] pairs or a list of " - "[alpha, beta, mach, reynolds, pitch_rate, yaw_rate, " - "roll_rate, coefficient] entries." - ) from e - - raise TypeError( - f"Invalid input for {coeff_name}: must be int, float, CSV file path, " - "Function, or callable." - ) - - def __load_rocket_drag_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load Rocket drag CSV into a 7D Function. - - Supports either headerless two-column (mach, coefficient) tables or - header-based multi-variable CSV tables. - """ - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - def _is_numeric(value): - try: - float(value) - return True - except (TypeError, ValueError): - try: - int(value) - return True - except (TypeError, ValueError): - return False - - try: - with open(file_path, mode="r") as file: - reader = csv.reader(file) - first_row = next(reader) - except (FileNotFoundError, IOError) as e: - raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e - except StopIteration as e: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e - - if not first_row: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") - - is_headerless_two_column = len(first_row) == 2 and all( - _is_numeric(cell) for cell in first_row - ) - - if is_headerless_two_column: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="constant", - ) - - def mach_wrapper( - _alpha, - _beta, - mach, - _reynolds, - _pitch_rate, - _yaw_rate, - _roll_rate, - ): - return csv_func(mach) - - return Function( - mach_wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - header = [column.strip() for column in first_row] - present_columns = [col for col in independent_vars if col in header] - - invalid_columns = [col for col in header[:-1] if col not in independent_vars] - if invalid_columns: - raise ValueError( - f"Invalid independent variable(s) in {coeff_name} CSV: " - f"{invalid_columns}. Valid options are: {independent_vars}." - ) - - if header[-1] in independent_vars: - raise ValueError( - f"Last column in {coeff_name} CSV must be the coefficient " - "value, not an independent variable." - ) - - if not present_columns: - raise ValueError(f"No independent variables found in {coeff_name} CSV.") - - ordered_present_columns = [ - col for col in header[:-1] if col in independent_vars - ] - - csv_func = Function.from_regular_grid_csv( - file_path, - ordered_present_columns, - coeff_name, - extrapolation="constant", - ) - if csv_func is None: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="constant", - ) - - def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): - args_by_name = { - "alpha": alpha, - "beta": beta, - "mach": mach, - "reynolds": reynolds, - "pitch_rate": pitch_rate, - "yaw_rate": yaw_rate, - "roll_rate": roll_rate, - } - selected_args = [args_by_name[col] for col in ordered_present_columns] - return csv_func(*selected_args) - - return Function( - wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 11eb76477..781b47c0a 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -2377,20 +2377,26 @@ def realized_stability_margin(self): diameter = 2 * self.rocket.radius time = self.time - alpha = np.array( - [self.partial_angle_of_attack.get_value_opt(t) for t in time] - ) - beta = np.array([self.angle_of_sideslip.get_value_opt(t) for t in time]) + # These are all tabulated at exactly ``self.time`` (their source is + # ``column_stack([self.time, values])`` or same-grid Function + # arithmetic), so read the values straight from ``.source`` instead of + # re-evaluating per node. ``mach`` is reused by both the realized cp and + # the linear margin below. ``center_of_mass`` is a rocket-level Function + # on a different grid, so it still needs ``get_value_opt(t)``. + alpha = self.partial_angle_of_attack.source[:, 1] + beta = self.angle_of_sideslip.source[:, 1] + mach = self.mach_number.source[:, 1] + reynolds = self.reynolds_number.source[:, 1] center_of_pressure = np.array( [ self.rocket.center_of_pressure( np.deg2rad(a), np.deg2rad(b), - self.mach_number.get_value_opt(t), - self.reynolds_number.get_value_opt(t), + m, + re, ) - for a, b, t in zip(alpha, beta, time) + for a, b, m, re in zip(alpha, beta, mach, reynolds) ] ) center_of_mass = np.array( @@ -2400,10 +2406,8 @@ def realized_stability_margin(self): margin_model = np.array( [ - self.rocket.stability_margin.get_value_opt( - self.mach_number.get_value_opt(t), t - ) - for t in time + self.rocket.stability_margin.get_value_opt(m, t) + for m, t in zip(mach, time) ] ) @@ -2417,9 +2421,7 @@ def realized_stability_margin(self): # Fall back fully to the linear margin where the rocket is barely moving # (dynamic pressure below 1% of its flight-wide peak: rail, rest, apogee). - dynamic_pressure = np.array( - [self.dynamic_pressure.get_value_opt(t) for t in time] - ) + dynamic_pressure = self.dynamic_pressure.source[:, 1] meaningful = dynamic_pressure > 0.01 * dynamic_pressure.max() margin = np.where(meaningful, margin_blended, margin_model) @@ -2446,9 +2448,7 @@ def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): total = propellant_mass + dry_mass mu = (propellant_mass * dry_mass / total) if total > 0 else 0.0 inertia[i] = ( - dry_lateral_inertia - + motor_lateral_inertia.get_value_opt(t) - + mu * b**2 + dry_lateral_inertia + motor_lateral_inertia.get_value_opt(t) + mu * b**2 ) return inertia @@ -2494,8 +2494,7 @@ def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): for surface, position in self.rocket.aerodynamic_surfaces: slope = surface.lift_coefficient_derivative.get_value_opt(mach) cp_position = ( - position.z - - csys * surface.center_of_pressure_z.get_value_opt(mach) + position.z - csys * surface.center_of_pressure_z.get_value_opt(mach) ) arm = cp_position - center_of_mass ref_factor = surface.reference_area / area @@ -2503,9 +2502,10 @@ def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): damping_aero *= 0.5 * density * speed * area # Jet (propulsive) damping: mdot (x_nozzle - x_cm)^2. - damping_jet = abs(mass_flow_rate.get_value_opt(t)) * ( - nozzle_position - center_of_mass - ) ** 2 + damping_jet = ( + abs(mass_flow_rate.get_value_opt(t)) + * (nozzle_position - center_of_mass) ** 2 + ) damping[i] = damping_aero + damping_jet positive_corrective = np.clip(corrective, 0.0, None) diff --git a/rocketpy/simulation/helpers/flight_derivatives.py b/rocketpy/simulation/helpers/flight_derivatives.py index ac28b08cd..dcd275076 100644 --- a/rocketpy/simulation/helpers/flight_derivatives.py +++ b/rocketpy/simulation/helpers/flight_derivatives.py @@ -91,9 +91,23 @@ def _aerodynamic_drag_force( # Air brakes are drag-only and may override the rocket drag. for air_brakes in rocket.air_brakes: if air_brakes.deployment_level > 0: + # Air brakes are a (controllable) generic surface, so feed the + # coefficient the non-dimensional reduced rates, like every other + # generic surface (see GenericSurface.compute_forces_and_moments). + reduced = ( + air_brakes.reference_length / (2 * stream_speed) + if stream_speed > 0 + else 0.0 + ) air_brakes_cd = air_brakes.cD.get_value_opt( *air_brakes._coefficient_arguments( - alpha, beta, mach, reynolds, omega[0], omega[1], omega[2] + alpha, + beta, + mach, + reynolds, + omega[0] * reduced, + omega[1] * reduced, + omega[2] * reduced, ) ) air_brakes_force = ( @@ -342,7 +356,14 @@ def u_dot(flight, t, u, post_processing=False): dynamic_viscosity, ) R3 = _aerodynamic_drag_force( - flight, t, rho, free_stream_speed, alpha, beta, mach, reynolds, + flight, + t, + rho, + free_stream_speed, + alpha, + beta, + mach, + reynolds, (omega1, omega2, omega3), ) # Off center moment @@ -578,7 +599,14 @@ def u_dot_generalized_3dof(flight, t, u, post_processing=False): # Drag computation (rocket body drag + air brakes) R1, R2 = 0, 0 R3 = _aerodynamic_drag_force( - flight, t, rho, free_stream_speed, alpha, beta, mach, reynolds, + flight, + t, + rho, + free_stream_speed, + alpha, + beta, + mach, + reynolds, (omega1, omega2, omega3), ) @@ -816,7 +844,14 @@ def u_dot_generalized(flight, t, u, post_processing=False): else: net_thrust = 0 R3 = _aerodynamic_drag_force( - flight, t, rho, free_stream_speed, alpha, beta, mach, reynolds, + flight, + t, + rho, + free_stream_speed, + alpha, + beta, + mach, + reynolds, (omega1, omega2, omega3), ) # Get rocket velocity in body frame From e76c31c3ccce817caf29dc7115bb25f02298f004 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Thu, 2 Jul 2026 08:16:38 -0300 Subject: [PATCH 3/8] TST: add tests --- .pylintrc | 2 + .../center_of_pressure_and_stability.rst | 107 +++++---- docs/user/rocket/generic_surface.rst | 22 +- rocketpy/plots/flight_plots.py | 6 - rocketpy/plots/rocket_plots.py | 38 ++- .../controllable_generic_surface.py | 33 ++- .../aero_surface/fins/elliptical_fin.py | 4 +- .../rocket/aero_surface/fins/free_form_fin.py | 4 +- .../aero_surface/fins/trapezoidal_fin.py | 4 +- rocketpy/rocket/rocket.py | 168 ++++--------- rocketpy/simulation/flight.py | 87 +------ .../aero_surface/test_aero_coefficient.py | 226 ++++++++++++++++++ .../test_barrowman_generic_equivalence.py | 163 +++++++++++++ .../test_controllable_generic_surface.py | 101 ++++++++ .../aero_surface/test_generic_surfaces.py | 39 ++- .../aero_surface/test_individual_fins.py | 122 +++++++++- .../test_linear_generic_surfaces.py | 30 +++ .../test_unsteady_generic_surface.py | 71 ++++++ tests/unit/rocket/test_rocket.py | 46 ++-- tests/unit/rocket/test_stability_rework.py | 93 +++++++ tests/unit/simulation/test_event_scheduler.py | 0 tests/unit/simulation/test_flight.py | 10 +- 22 files changed, 1046 insertions(+), 330 deletions(-) create mode 100644 tests/unit/rocket/aero_surface/test_aero_coefficient.py create mode 100644 tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py create mode 100644 tests/unit/rocket/aero_surface/test_controllable_generic_surface.py create mode 100644 tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py create mode 100644 tests/unit/rocket/test_stability_rework.py create mode 100644 tests/unit/simulation/test_event_scheduler.py diff --git a/.pylintrc b/.pylintrc index 3b059ee2c..9e4fd8e89 100644 --- a/.pylintrc +++ b/.pylintrc @@ -231,6 +231,8 @@ good-names=FlightPhases, R_uncanted, R_body_to_fin, Re, # Reynolds number + cL_alpha, + cQ_beta, # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted diff --git a/docs/technical/aerodynamics/center_of_pressure_and_stability.rst b/docs/technical/aerodynamics/center_of_pressure_and_stability.rst index 42f281cab..80e7de108 100644 --- a/docs/technical/aerodynamics/center_of_pressure_and_stability.rst +++ b/docs/technical/aerodynamics/center_of_pressure_and_stability.rst @@ -143,10 +143,9 @@ orientation. Because the weight is the normal-force slope, a zero-lift surface ``Rocket.aerodynamic_center``. .. note:: - ``Rocket.cp_position`` is a **deprecated alias** for - ``Rocket.aerodynamic_center``. The historical "center of pressure" attribute - was always the aerodynamic center; the alias is kept (with a - ``DeprecationWarning``) for backward compatibility. + ``Rocket.cp_position`` is an **alias** for ``Rocket.aerodynamic_center``. The + historical "center of pressure" attribute was always the aerodynamic center; + the alias is kept for backward compatibility and convenience. Center of pressure (nonlinear) ------------------------------ @@ -161,17 +160,21 @@ attack/sideslip: x_\text{CP}(\alpha,\beta,M,Re) = x_\text{cdm} + c\,\frac{M_2 R_1 - M_1 R_2}{R_1^2 + R_2^2} -evaluated from the Layer-1 aggregate (:math:`M = r\times F`). Unlike the AC, the -CP **moves with incidence**. It is a :math:`0/0` limit at zero incidence and -converges to the AC as :math:`\alpha,\beta \to 0`. This is -:meth:`rocketpy.Rocket.center_of_pressure`. - -To stay well-conditioned, ``center_of_pressure`` returns the aerodynamic-center -limit below ~1° of total incidence — blended between the pitch and yaw planes by -the direction of incidence (:meth:`rocketpy.Rocket._aerodynamic_center_limit`) — -so it is continuous and never spikes as the rocket oscillates through zero -incidence. The design-time travel is exposed by -``center_of_pressure_over_alpha`` and ``center_of_pressure_over_beta``. +evaluated from the Layer-1 aggregate (:math:`M = r\times F`). Equivalently, per +plane, :math:`x_\text{CP} = x_\text{cdm} + c\,L_\text{ref}\,C_m/C_L` (pitch) and +:math:`+\,c\,L_\text{ref}\,C_n/C_Q` (yaw). Unlike the AC, the CP **moves with +incidence**. + +The CP is a **genuinely partial quantity**: it is a :math:`0/0` limit at zero +incidence (the normal force vanishes), undefined there, and converges to the AC +as :math:`\alpha,\beta \to 0`. Because it plays no role in the equations of +motion and the stability margins are correctly built on the AC (a slope, see +Layer 3), RocketPy does **not** expose it as a dedicated method — that would +force an arbitrary regularization of a real singularity. When the +force-application CP is genuinely wanted (e.g. comparing against wind-tunnel or +CFD CP-vs-:math:`\alpha` data), it is reconstructed on demand from the aggregate +coefficients :meth:`rocketpy.Rocket.aerodynamic_coefficients_full` using the +relation above, with the caller deciding how to treat the zero-incidence limit. Pitch and yaw planes -------------------- @@ -187,8 +190,7 @@ They coincide for an axisymmetric rocket; ``Rocket.is_axisymmetric`` reports whether they agree (to caliber tolerance) and :meth:`rocketpy.Rocket.evaluate_center_of_pressure` warns when they do not, since the scalar ``static_margin``/``stability_margin`` then describe the pitch plane -only. The **nonlinear** CP needs no such split — evaluated at the actual combined -incidence, a single axial location already captures both planes. +only, and the ``*_yaw`` counterparts expose the yaw plane. Layer 3 — Static and stability margins ====================================== @@ -213,24 +215,37 @@ incompressible (:math:`M=0`) limit, a function of time; the stability margin (:meth:`rocketpy.Rocket.evaluate_stability_margin`) is a function of Mach and time. The ``*_yaw`` counterparts use ``aerodynamic_center_yaw``. -**Realized (center-of-pressure) margin.** Built on the nonlinear CP at the -actual flight incidence, it reflects how the stability reference travels with -:math:`\alpha,\beta` (and combines the planes for a non-axisymmetric rocket). - -At the :class:`rocketpy.Flight` level: - -- ``Flight.stability_margin`` evaluates the **linear** margin along the realized - Mach and time — smooth, conventional, and the source of - ``initial_stability_margin`` / ``out_of_rail_stability_margin`` / - ``min_stability_margin`` / ``max_stability_margin``; -- ``Flight.realized_stability_margin`` evaluates the **nonlinear** CP at the - realized :math:`\alpha,\beta,M,Re`, falling back to the linear margin only at - negligible dynamic pressure (rail, rest, apogee), where the realized incidence - is meaningless. +At the :class:`rocketpy.Flight` level, ``Flight.stability_margin`` (and +``stability_margin_yaw``) evaluates the linear margin along the realized Mach and +time — smooth, conventional, and the source of ``initial_stability_margin`` / +``out_of_rail_stability_margin`` / ``min_stability_margin`` / +``max_stability_margin``. A positive margin (stability reference behind the center of mass) is the classic passive-stability condition. +.. note:: + **Nonlinear (large-incidence) static stability.** For tabulated + :class:`rocketpy.GenericSurface` coefficients that are nonlinear in + :math:`\alpha`, the stability reference -- the *local neutral point*, the AC + re-linearized at the flown incidence -- migrates with angle of attack, + + .. math:: + + x_\text{NP}(\alpha,\beta,M) = x_\text{cdm} + + c\,L_\text{ref}\,\frac{\partial C_m/\partial\alpha} + {\partial C_L/\partial\alpha}, + + which (unlike the singular force-application CP :math:`-C_m/C_N`) is well + conditioned at every incidence and isolates the stability-relevant part of + the CP travel. It is reconstructed on demand from + :meth:`rocketpy.Rocket.aerodynamic_coefficients_full` by a central finite + difference in :math:`\alpha`. For linear Barrowman aerodynamics it reduces to + the :math:`\alpha=0` AC, so the linear margin already captures it; only + nonlinear tabulated coefficients make it move. Large-incidence stability is + usually read more meaningfully from the dynamic-stability coefficients + (Layer 4). + Layer 4 — Dynamic stability =========================== @@ -292,31 +307,23 @@ Quick reference * - ``Rocket.aerodynamic_center`` (``_yaw``) - :math:`M` - Linear (small-incidence) center of pressure; static-margin reference. - ``cp_position`` is a deprecated alias. - * - ``Rocket.center_of_pressure(α, β, M, Re)`` - - :math:`\alpha,\beta,M,Re` - - Nonlinear CP at finite incidence; combines both planes. - * - ``Rocket.center_of_pressure_over_{alpha,beta}`` - - :math:`\alpha` / :math:`\beta` - - CP travel sweep (design time). + ``cp_position`` is an alias. * - ``Rocket.aerodynamic_coefficients(α, β, M, Re)`` - :math:`\alpha,\beta,M,Re` - Total :math:`C_N`, :math:`C_m` about the center of dry mass. + * - ``Rocket.aerodynamic_coefficients_full(α, β, M, Re)`` + - :math:`\alpha,\beta,M,Re` + - Six signed coefficients; reconstruct the nonlinear CP as + :math:`x_\text{cdm} + c\,L_\text{ref}\,C_m/C_L`. * - ``Rocket.static_margin`` (``_yaw``) - :math:`t` - Linear margin at :math:`M=0` (calibers). * - ``Rocket.stability_margin`` (``_yaw``) - :math:`M, t` - Linear margin vs Mach and time (calibers). - * - ``Rocket.stability_margin_over_{alpha,beta}`` - - :math:`\alpha` / :math:`\beta` - - Nonlinear margin travel sweep (design time). - * - ``Flight.stability_margin`` + * - ``Flight.stability_margin`` (``_yaw``) - :math:`t` - Linear margin along the realized Mach(t) — smooth. - * - ``Flight.realized_stability_margin`` - - :math:`t` - - Nonlinear margin at the realized incidence. * - ``Flight.{pitch,yaw}_natural_frequency`` - :math:`t` - Attitude oscillation natural frequency :math:`\omega_n`. @@ -331,11 +338,9 @@ Visualizing stability ===================== - ``Rocket.plots.stability_margin`` — linear margin vs Mach and time (surface). -- ``Rocket.plots.stability_margin_over_alpha`` / ``_over_beta`` — nonlinear - margin travel with incidence (yaw sweep shown when non-axisymmetric). - ``Rocket.plots.aerodynamic_coefficients`` — :math:`C_N`, :math:`C_m` vs :math:`\alpha`; ``drag_curves`` for :math:`C_D` vs Mach. -- ``Flight.plots.stability_and_control_data`` — linear and realized margin vs +- ``Flight.plots.stability_and_control_data`` — linear margin (pitch and yaw) vs time, plus the FFT frequency response. - ``Flight.plots.dynamic_stability_data`` — natural frequency and damping ratio vs time (pitch and yaw). @@ -385,8 +390,10 @@ generic coefficient path as every other surface. The independent :math:`\alpha,\ \beta` decomposition of the linear model coincides with the classical single-plane Barrowman projection to first order and diverges only at large combined angle of attack, where the - underlying linear coefficients are themselves no longer valid; the nonlinear - :meth:`rocketpy.Rocket.center_of_pressure` captures that regime. + underlying linear coefficients are themselves no longer valid; that regime is + captured by tabulated :class:`rocketpy.GenericSurface` coefficients, from + which the local neutral point and the nonlinear CP can be reconstructed via + :meth:`rocketpy.Rocket.aerodynamic_coefficients_full`. References ========== diff --git a/docs/user/rocket/generic_surface.rst b/docs/user/rocket/generic_surface.rst index 997f5a178..bf9bdcfd0 100644 --- a/docs/user/rocket/generic_surface.rst +++ b/docs/user/rocket/generic_surface.rst @@ -199,9 +199,25 @@ The coefficients are all functions of: - Side slip angle (:math:`\beta`) in radians. - Mach number (:math:`Ma`). - Reynolds number (:math:`Re`). -- Pitch rate (:math:`q`) in radians per second. -- Yaw rate (:math:`r`) in radians per second. -- Roll rate (:math:`p`) in radians per second. +- Pitch rate (:math:`q^{*}`), non-dimensional (reduced). +- Yaw rate (:math:`r^{*}`), non-dimensional (reduced). +- Roll rate (:math:`p^{*}`), non-dimensional (reduced). + +.. important:: + The angular rates are the conventional **non-dimensional reduced rates**, not + the raw body rates in rad/s: + + .. math:: + q^{*} = \frac{q \, L_{ref}}{2 V}, \quad + r^{*} = \frac{r \, L_{ref}}{2 V}, \quad + p^{*} = \frac{p \, L_{ref}}{2 V} + + where :math:`L_{ref}` is the surface reference length and :math:`V` the + freestream speed. This matches how published and tool-generated aerotables + (Missile DATCOM, OpenVSP, CFD/wind-tunnel sweeps) tabulate rate derivatives, + so such tables can be used directly. RocketPy non-dimensionalizes the body + rates internally before evaluating the coefficients (the factor is 0 at zero + airspeed). Define your tables against the reduced rates. .. math:: \begin{aligned} diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index f162f268b..99a528fb7 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -1277,12 +1277,6 @@ def stability_and_control_data(self, *, filename=None): # pylint: disable=too-m self.flight.stability_margin_yaw[:, 1], label="Linear yaw", ) - ax1.plot( - self.flight.realized_stability_margin[:, 0], - self.flight.realized_stability_margin[:, 1], - label="Realized (nonlinear CP)", - linestyle="--", - ) ax1.set_title("Stability Margin") ax1.set_xlabel("Time (s)") ax1.set_ylabel("Stability Margin (c)") diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 95a746151..02869ece6 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -775,25 +775,37 @@ def _draw_center_of_mass_and_pressure(self, ax, plane="xz"): ax.scatter(cp, 0, label="Center of Pressure", color="red", s=10, zorder=10) def _center_of_pressure_range(self, plane, max_angle=np.deg2rad(15), samples=31): - """Min and max center-of-pressure position over an incidence sweep. + """Min and max nonlinear center-of-pressure position over an incidence + sweep. Sweeps the angle of attack (xz plane) or sideslip (yz plane) from 0 to - ``max_angle`` using the nonlinear :meth:`Rocket.center_of_pressure` and - returns the extent of the resulting center-of-pressure travel. + ``max_angle`` and reconstructs the nonlinear center of pressure -- + ``x_cdm + csys * d * Cm / CN`` -- from the rocket aerodynamic + coefficients (:meth:`Rocket.aerodynamic_coefficients_full`), returning + the extent of its travel. The center of pressure is singular at zero + incidence (``CN -> 0``); those samples are skipped. """ + rocket = self.rocket + csys = rocket._csys + diameter = 2 * rocket.radius + cdm = rocket.center_of_dry_mass_position angles = np.linspace(0, max_angle, samples) - if plane == "yz": - positions = np.array( - [self.rocket.center_of_pressure(0.0, b, 0.0) for b in angles] - ) - else: - positions = np.array( - [self.rocket.center_of_pressure(a, 0.0, 0.0) for a in angles] - ) - positions = positions[np.isfinite(positions)] + positions = [] + for angle in angles: + if plane == "yz": + coeffs = rocket.aerodynamic_coefficients_full(0.0, angle, 0.0) + force, moment = coeffs["cQ"], coeffs["cn"] + else: + coeffs = rocket.aerodynamic_coefficients_full(angle, 0.0, 0.0) + force, moment = coeffs["cL"], coeffs["cm"] + if force == 0: + continue + position = cdm + csys * diameter * moment / force + if np.isfinite(position): + positions.append(position) if len(positions) == 0: return (0.0, 0.0) - return (float(positions.min()), float(positions.max())) + return (float(min(positions)), float(max(positions))) def _draw_sensors(self, ax, sensors, plane): """Draw the sensor as a small thick line at the position of the sensor, diff --git a/rocketpy/rocket/aero_surface/controllable_generic_surface.py b/rocketpy/rocket/aero_surface/controllable_generic_surface.py index 049ba6206..793c49936 100644 --- a/rocketpy/rocket/aero_surface/controllable_generic_surface.py +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -22,6 +22,35 @@ class ControllableGenericSurface(GenericSurface): Current value of each control variable (defaults to 0). """ + # TODO: deflection-dependent static-margin diagnostics. + # + # The in-flight dynamics are correct: the deflection feeds the coefficient + # functions live every step (see ``_coefficient_arguments``), and the surface + # never physically moves, so its force-application point / ``cp_to_cdm`` cache + # cannot go stale (unlike an individual fin's cant angle, which IS a physical + # reconfiguration and is refreshed via ``Rocket.refresh_controlled_components``). + # + # The gap is diagnostic-only. The derived ``center_of_pressure_z`` / + # ``aerodynamic_center`` come from ``cm_alpha = d(cm)/d(alpha)`` evaluated ONCE + # (in ``_set_derived_cp_accessors``) with the control variables frozen at their + # value at construction (0). So if ``cm`` couples alpha and a control axis + # (e.g. an ``alpha * deflection`` term), the reported ``static_margin`` is + # pinned to the zero-deflection configuration and does not track ``set_control``. + # It also is not a single well-defined number: the static margin of a deflected + # control surface is inherently a function of the control input. + # + # To address this properly (not a correctness fix, defer until there is a real + # need), likely some combination of: + # - an ``initial_deflection`` (per-control) argument in ``__init__`` so the + # derived cp accessors are built about a chosen reference deflection rather + # than always 0; + # - re-deriving the cp accessors when the deflection changes -- reuse the + # fin mechanism: bump ``_geometry_version`` in ``set_control`` and have + # ``Rocket.refresh_controlled_components`` re-run the derived-cp step; + # - dedicated stability plots/prints that sweep the static margin (and cp) + # OVER the control-deflection range, since a single scalar margin is the + # wrong abstraction for a controllable surface. + def __init__( self, reference_area, @@ -126,7 +155,9 @@ def get_control(self, name): """Return the current value of a control variable.""" return self.control_state[name] - def to_dict(self, include_outputs=False): # pylint: disable=unused-argument + def to_dict( # pylint: disable=unused-argument + self, include_outputs=False, **kwargs + ): return { "reference_area": self.reference_area, "reference_length": self.reference_length, diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py index f809bca29..249a8cf70 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py @@ -175,8 +175,8 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py index 5099d322b..bf6565010 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fin.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py @@ -172,8 +172,8 @@ def evaluate_center_of_pressure(self): def shape_points(self): return self.geometry.shape_points - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py index f6bf1a7cd..49e594ec1 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py @@ -219,8 +219,8 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index f8b4fc0f1..48ad2fa85 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -136,11 +136,9 @@ class Rocket: Rocket.aerodynamic_center : Function Function of Mach number expressing the rocket's aerodynamic center (the linearized, small-incidence center of pressure) position relative - to the user defined rocket reference system. The nonlinear center of - pressure at a finite angle of attack is :meth:`Rocket.center_of_pressure`. - ``Rocket.cp_position`` is a deprecated alias for this attribute. - See :doc:`Positions and Coordinate Systems ` - for more information. + to the user defined rocket reference system. ``Rocket.cp_position`` is an + alias for this attribute. See :doc:`Positions and Coordinate Systems + ` for more information. Rocket.stability_margin : Function Stability margin of the rocket, in calibers, as a function of mach number and time. Stability margin is defined as the distance between @@ -452,6 +450,9 @@ def __init__( # pylint: disable=too-many-statements # the motor have been added. self._cp_outdated = True self._margin_outdated = True + # One-shot guard for the non-axisymmetric advisory (see + # ``evaluate_center_of_pressure``); warned at most once per rocket. + self._axisymmetry_warned = False # Initialize plots and prints object self.prints = _RocketPrints(self) @@ -726,9 +727,10 @@ def evaluate_center_of_pressure(self): The aerodynamic center is the linearized (small-incidence, :math:`\\alpha=\\beta=0`) center of pressure: the normal-force-slope- weighted average of every aerodynamic surface's location. It is the - well-conditioned reference used by the static and stability margins and - is distinct from :meth:`center_of_pressure`, which is the *nonlinear* - center of pressure at a finite angle of attack/sideslip. + well-conditioned reference used by the static and stability margins. The + nonlinear center of pressure at a finite angle of attack/sideslip is a + separate, singular quantity (``x_cdm + csys * d * Cm / CN``) that can be + reconstructed from :meth:`aerodynamic_coefficients_full` when needed. It is computed independently for the **pitch** plane (``aerodynamic_center``, from the normal-force/pitch-moment slopes) and @@ -784,6 +786,25 @@ def evaluate_center_of_pressure(self): if self._total_side_coeff_der.get_value(0) != 0: self._aerodynamic_center_yaw /= self._total_side_coeff_der + # One-shot non-axisymmetry advisory. Both plane centers are already built + # here, so detection costs only a Mach sweep -- no extra evaluation and no + # per-surface-add repetition. ``_cp_outdated`` was cleared at the top, so + # reading ``is_axisymmetric`` (which reads the centers) does not recurse. + # Emitted at most once per rocket, when the scalar pitch-plane margins + # first become potentially misleading. + if not self._axisymmetry_warned and not self.is_axisymmetric: + self._axisymmetry_warned = True + max_diff = self._cp_plane_max_difference() + warnings.warn( + "Pitch- and yaw-plane aerodynamic centers differ " + f"(max difference ~{max_diff:.4g} m): the rocket is not " + "axisymmetric. 'aerodynamic_center', 'static_margin' and " + "'stability_margin' describe the PITCH plane; use " + "'aerodynamic_center_yaw', 'static_margin_yaw' and " + "'stability_margin_yaw' for the yaw plane.", + stacklevel=2, + ) + return self._aerodynamic_center def _cp_plane_max_difference(self): @@ -836,119 +857,20 @@ def is_axisymmetric(self): # Tolerance relative to the rocket diameter (caliber-scale). return self._cp_plane_max_difference() <= 1e-6 * (2 * self.radius) - def _warn_if_not_axisymmetric(self): - """Warn, at surface-add time, when the rocket is non-axisymmetric so the - user knows the scalar ``static_margin``/``stability_margin`` describe the - pitch plane only. Short-circuits with no computation for rockets built - solely from axisymmetric-by-construction surfaces (the Barrowman set); - only a generic surface or individual fin triggers the Mach sweep.""" - if not self.aerodynamic_surfaces or self.is_axisymmetric: - return - max_diff = self._cp_plane_max_difference() - warnings.warn( - "Pitch- and yaw-plane aerodynamic centers differ " - f"(max difference ~{max_diff:.4g} m): the rocket is not " - "axisymmetric. 'aerodynamic_center', 'static_margin' and " - "'stability_margin' describe the PITCH plane; use " - "'aerodynamic_center_yaw', 'static_margin_yaw' and " - "'stability_margin_yaw' for the yaw plane.", - stacklevel=3, - ) - @property def cp_position(self): - """Deprecated alias for :attr:`aerodynamic_center` (the linearized, - Mach-dependent center of pressure / aerodynamic center).""" - warnings.warn( - "'cp_position' is deprecated and will be removed in a future " - "release; use 'aerodynamic_center' (the linearized center of " - "pressure) instead. For the nonlinear center of pressure at a given " - "angle of attack use 'center_of_pressure(alpha, beta, mach)'.", - DeprecationWarning, - stacklevel=2, - ) - return self.aerodynamic_center - - def _aerodynamic_center_limit(self, alpha, beta, mach): - """Small-incidence limit of :meth:`center_of_pressure`: the linearized - aerodynamic center, blended between the pitch and yaw planes by the - squared normal-force contribution of each, so the nonlinear center of - pressure stays continuous in the ``(alpha, beta)`` direction as the - incidence goes to zero (pure pitch -> pitch AC, pure sideslip -> yaw AC). + """Alias for :attr:`aerodynamic_center`. + + Historically named "center of pressure", this is the linearized, + Mach-dependent aerodynamic center -- the slope-weighted (Barrowman) + quantity the rocketry community conventionally calls the CP, and the + well-conditioned reference used by the static and stability margins. + The genuine, force-application center of pressure at a finite incidence + is ``x_cdm + csys * d * Cm / CN`` and can be reconstructed on demand from + :meth:`aerodynamic_coefficients_full`; it is intentionally not exposed as + a method because it is singular at zero normal force (zero incidence). """ - weight_pitch = (self.total_lift_coeff_der.get_value_opt(mach) * alpha) ** 2 - weight_yaw = (self.total_side_coeff_der.get_value_opt(mach) * beta) ** 2 - pitch = self.aerodynamic_center.get_value_opt(mach) - if weight_pitch + weight_yaw == 0: - return pitch - yaw = self.aerodynamic_center_yaw.get_value_opt(mach) - return (pitch * weight_pitch + yaw * weight_yaw) / (weight_pitch + weight_yaw) - - def center_of_pressure(self, alpha, beta, mach, reynolds=0.0): - """Nonlinear center of pressure axial position, as a function of the - aerodynamic state. - - Unlike :attr:`aerodynamic_center` (the linearized center of pressure, a - function of Mach alone, valid only near zero incidence), this aggregates - the *actual* force and moment of every aerodynamic surface at the - requested angle of attack ``alpha``, sideslip ``beta`` and Mach number, - then locates the axial point about which the resultant transverse - aerodynamic force produces no moment: - ``z_cp = (M2*R1 - M1*R2) / (R1**2 + R2**2)`` (about the center of dry - mass, from ``M = r x F``). - - Because the resultant force is evaluated at the actual *combined* - incidence, a single axial location captures both the pitch and the yaw - plane -- the ``aerodynamic_center``/``aerodynamic_center_yaw`` split is - only needed for the linearized slopes, which must pick a perturbation - axis. As ``alpha, beta -> 0`` the normal force vanishes and the location - becomes a ``0/0`` limit; there the linearized aerodynamic center - (:attr:`aerodynamic_center`) is returned, which the nonlinear value - converges to. - - Parameters - ---------- - alpha : float - Angle of attack, in radians (pitch plane, body aerodynamic frame). - beta : float - Sideslip angle, in radians (yaw plane, body aerodynamic frame). - mach : float - Free-stream Mach number. - reynolds : float, optional - Rocket-level Reynolds number (based on the rocket diameter). Each - surface's Reynolds number is scaled to its own reference length. - Defaults to ``0`` (vanishing-Reynolds limit, matching the linearized - ``aerodynamic_center`` convention). - - Returns - ------- - float - Center of pressure position along the rocket axis, in the rocket - coordinate system (m). See :doc:`Positions and Coordinate Systems - `. - """ - # Below ~1 deg total incidence the normal force is too small for the - # moment/normal-force ratio to be well conditioned (a 0/0 limit at the - # origin, finite-precision noise just above it). Return the linearized - # aerodynamic center, which the nonlinear value converges to, so the - # result is continuous and never spikes as the rocket oscillates through - # zero incidence. - if alpha**2 + beta**2 < math.radians(1.0) ** 2 or ( - len(self.aerodynamic_surfaces) == 0 - ): - return self._aerodynamic_center_limit(alpha, beta, mach) - - total_x, total_y, _, moment_x, moment_y, _, _ = ( - self._aerodynamic_forces_and_moments(alpha, beta, mach, reynolds) - ) - - normal_force_sq = total_x**2 + total_y**2 - if normal_force_sq == 0: - return self._aerodynamic_center_limit(alpha, beta, mach) - # Axial offset (body frame) from the center of dry mass to the line of - # action of the resultant transverse force. - cp_offset = (moment_y * total_x - moment_x * total_y) / normal_force_sq - return self.center_of_dry_mass_position + self._csys * cp_offset + return self.aerodynamic_center def _aerodynamic_forces_and_moments(self, alpha, beta, mach, reynolds=0.0): """Total body-frame aerodynamic force ``(R1, R2, R3)`` and moment @@ -1625,15 +1547,13 @@ def add_surfaces(self, surfaces, positions): self.__add_single_surface(surfaces, positions) # Adding a surface changes both the aerodynamic center and the margins; - # flag them for lazy rebuild on next access (see the properties). + # flag them for lazy rebuild on next access (see the properties). The + # non-axisymmetry advisory is emitted (once) from + # ``evaluate_center_of_pressure`` on that first rebuild, rather than + # eagerly here on every add. self._cp_outdated = True self._margin_outdated = True - # The asymmetry warning is the one piece evaluated eagerly: it is a - # setup-time hint about which margin attributes to trust, and the check - # is free for axisymmetric-by-construction (Barrowman) rockets. - self._warn_if_not_axisymmetric() - def add_vehicle_aerodynamic_surface( self, coefficients, reference_position=None, name="Vehicle Aerodynamics" ): diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 781b47c0a..7c631753b 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -2312,9 +2312,7 @@ def stability_margin(self): (:meth:`Rocket.stability_margin`) at the realized flight Mach and time at each instant, capturing the Mach variation of the aerodynamic center together with the center-of-mass shift as propellant burns. It is - well-conditioned and never spikes. For the nonlinear margin that follows - the center of pressure at the actual angle of attack/sideslip, see - :meth:`realized_stability_margin`. + well-conditioned and never spikes. Returns ------- @@ -2344,89 +2342,6 @@ def stability_margin_yaw(self): (t, self.rocket.stability_margin_yaw(m, t)) for t, m in self.mach_number ] - @funcify_method("Time (s)", "Realized Stability Margin (c)", "linear", "zero") - def realized_stability_margin(self): - """Nonlinear (realized) stability margin along the flight, in calibers. - - Co-equal companion to :meth:`stability_margin`: instead of the - aerodynamic center it uses the rocket's *nonlinear* center of pressure - (:meth:`Rocket.center_of_pressure`) at the realized flight state -- the - actual angle of attack, sideslip, Mach and Reynolds at each time step -- - so it reveals how the center of pressure travels with incidence (and, - for non-axisymmetric rockets, combines the pitch and yaw planes at the - actual combined incidence). - - For a non-axisymmetric rocket the margin is **direction-dependent** (the - pitch and yaw planes differ), and during most of the flight the rocket - flies at near-zero incidence, where the *direction* of the residual - incidence vector is numerical noise (slight coning). Reporting the - directional center of pressure there would make the margin swing between - the pitch- and yaw-plane values. To avoid that, the realized value is - blended into the linear margin by how much real incidence there is: at - negligible incidence the result is the linear :meth:`stability_margin`, - and only a genuine disturbance (a few degrees of incidence) reveals the - nonlinear travel. The value also falls back to the linear margin where - the dynamic pressure is negligible (rail, rest, apogee). - - Returns - ------- - stability : rocketpy.Function - Realized stability margin in calibers as a function of time. - """ - csys = self.rocket._csys - diameter = 2 * self.rocket.radius - time = self.time - - # These are all tabulated at exactly ``self.time`` (their source is - # ``column_stack([self.time, values])`` or same-grid Function - # arithmetic), so read the values straight from ``.source`` instead of - # re-evaluating per node. ``mach`` is reused by both the realized cp and - # the linear margin below. ``center_of_mass`` is a rocket-level Function - # on a different grid, so it still needs ``get_value_opt(t)``. - alpha = self.partial_angle_of_attack.source[:, 1] - beta = self.angle_of_sideslip.source[:, 1] - mach = self.mach_number.source[:, 1] - reynolds = self.reynolds_number.source[:, 1] - - center_of_pressure = np.array( - [ - self.rocket.center_of_pressure( - np.deg2rad(a), - np.deg2rad(b), - m, - re, - ) - for a, b, m, re in zip(alpha, beta, mach, reynolds) - ] - ) - center_of_mass = np.array( - [self.rocket.center_of_mass.get_value_opt(t) for t in time] - ) - margin_realized = (center_of_mass - center_of_pressure) / diameter * csys - - margin_model = np.array( - [ - self.rocket.stability_margin.get_value_opt(m, t) - for m, t in zip(mach, time) - ] - ) - - # Weight the (direction-dependent) realized value by how much real - # incidence there is, with a smoothstep ramp up to ~2 deg, so the - # near-zero-incidence direction noise collapses to the linear margin. - incidence = np.hypot(alpha, beta) - weight = np.clip(incidence / 2.0, 0.0, 1.0) - weight = weight**2 * (3.0 - 2.0 * weight) - margin_blended = (1.0 - weight) * margin_model + weight * margin_realized - - # Fall back fully to the linear margin where the rocket is barely moving - # (dynamic pressure below 1% of its flight-wide peak: rail, rest, apogee). - dynamic_pressure = self.dynamic_pressure.source[:, 1] - meaningful = dynamic_pressure > 0.01 * dynamic_pressure.max() - margin = np.where(meaningful, margin_blended, margin_model) - - return np.column_stack((time, margin)) - # Dynamic stability def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): """Lateral moment of inertia about the instantaneous center of mass, as diff --git a/tests/unit/rocket/aero_surface/test_aero_coefficient.py b/tests/unit/rocket/aero_surface/test_aero_coefficient.py new file mode 100644 index 000000000..ed568773a --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_aero_coefficient.py @@ -0,0 +1,226 @@ +"""Unit tests for the AeroCoefficient minimal-dimension coefficient store.""" + +import pytest + +from rocketpy import Function +from rocketpy.rocket.aero_surface.aero_coefficient import ( + AeroCoefficient, + build_independent_vars, +) + +IV = ["alpha", "beta", "mach", "reynolds", "pitch_rate", "yaw_rate", "roll_rate"] + + +# -- Construction & evaluation ------------------------------------------------ + + +def test_constant_coefficient_is_zero_flagged(): + zero = AeroCoefficient(0, (), name="cD") + assert zero.is_zero is True + assert zero.is_zero_coefficient is True + assert zero(0.1, 0.2, 0.3, 0, 0, 0, 0) == 0.0 + + const = AeroCoefficient(0.7, (), name="cD") + assert const.is_zero is False + assert const(1, 2, 3, 4, 5, 6, 7) == 0.7 + assert const.get_value_opt(1, 2, 3, 4, 5, 6, 7) == 0.7 + + +def test_call_is_get_value_opt(): + # __call__ is aliased to get_value_opt; both must behave identically. + assert AeroCoefficient.__call__ is AeroCoefficient.get_value_opt + + +def test_mach_only_coefficient_maps_arguments(): + coeff = AeroCoefficient(lambda mach: 2 * mach, ("mach",), name="cL_alpha") + assert coeff.depends_on == ("mach",) + # Only the mach argument (index 2) should be used. + assert coeff(99, 99, 0.3, 99, 99, 99, 99) == pytest.approx(0.6) + assert coeff.get_value_opt(99, 99, 0.3, 99, 99, 99, 99) == pytest.approx(0.6) + + +def test_function_source_stored_directly(): + f = Function(lambda mach: mach**2, "mach", "cD") + coeff = AeroCoefficient(f, ("mach",), name="cD") + assert coeff.function is f + assert coeff(0, 0, 0.5, 0, 0, 0, 0) == pytest.approx(0.25) + + +def test_depends_on_preserves_source_argument_order(): + # depends_on order must match the source's positional order, even when it + # differs from the independent-variable order (e.g. shuffled CSV columns). + coeff = AeroCoefficient( + lambda mach, alpha: 10 * mach + alpha, ("mach", "alpha"), name="cL" + ) + # full args: alpha=1 (idx0), mach=2 (idx2) -> source(mach=2, alpha=1) = 21 + assert coeff(1, 0, 2, 0, 0, 0, 0) == pytest.approx(21) + + +def test_unknown_dependency_raises(): + with pytest.raises(ValueError, match="unknown variable"): + AeroCoefficient(lambda x: x, ("bogus",), name="cL") + + +def test_dom_dim_matches_full_arity(): + coeff = AeroCoefficient(0, (), name="cD") + assert coeff.__dom_dim__ == len(IV) + + +def test_repr_constant_and_function(): + assert "0.5" in repr(AeroCoefficient(0.5, (), name="cD")) + function_repr = repr(AeroCoefficient(lambda mach: mach, ("mach",), name="cL")) + assert "depends_on" in function_repr and "mach" in function_repr + + +# -- Independent-variable axes (unsteady / control) --------------------------- + + +def test_build_independent_vars_base_unsteady_and_controls(): + assert build_independent_vars() == IV + assert build_independent_vars(unsteady_aero=True) == IV + ["alpha_dot", "beta_dot"] + assert build_independent_vars(control_variables=("defl",)) == IV + ["defl"] + + +def test_unsteady_aero_extends_independent_vars(): + coeff = AeroCoefficient( + lambda alpha_dot: alpha_dot, ("alpha_dot",), unsteady_aero=True, name="cL" + ) + assert coeff.independent_vars == tuple(IV + ["alpha_dot", "beta_dot"]) + assert coeff.__dom_dim__ == 9 + # alpha_dot is the 8th argument (index 7). + assert coeff(0, 0, 0, 0, 0, 0, 0, 1.5, 0) == pytest.approx(1.5) + + +def test_control_variable_axis_is_appended(): + coeff = AeroCoefficient( + lambda deflection: 2 * deflection, + ("deflection",), + control_variables=("deflection",), + name="cL", + ) + assert coeff.independent_vars[-1] == "deflection" + assert coeff(0, 0, 0, 0, 0, 0, 0, 4) == pytest.approx(8) + + +# -- constructor inference: scalar ------------------------------------------------------- + + +def test_from_input_scalar(): + coeff = AeroCoefficient(0, name="cm") + assert coeff.is_zero is True + + +def test_from_input_non_numeric_raises(): + with pytest.raises(TypeError, match="must be a number"): + AeroCoefficient(object(), name="cD") + + +# -- constructor inference: callable ----------------------------------------------------- + + +def test_from_input_full_arity_callable(): + coeff = AeroCoefficient(lambda a, b, m, r, p, q, rr: a + m, name="cL") + assert coeff.depends_on == tuple(IV) + assert coeff(0.1, 0, 0.3, 0, 0, 0, 0) == pytest.approx(0.4) + + +def test_from_input_named_subset_callable(): + coeff = AeroCoefficient(lambda alpha, mach: alpha * mach, name="cL") + assert coeff.depends_on == ("alpha", "mach") + assert coeff(2, 0, 3, 0, 0, 0, 0) == pytest.approx(6) + + +def test_from_input_rejects_unmappable_callable(): + with pytest.raises(ValueError, match="callable must accept"): + AeroCoefficient(lambda x, y, z: x, name="cL") + + +# -- constructor inference: Function ----------------------------------------------------- + + +def test_from_input_full_dim_function(): + f = Function(lambda a, b, m, r, p, q, rr: a + m, IV, "cL") + coeff = AeroCoefficient(f, name="cL") + assert coeff.depends_on == tuple(IV) + assert coeff(0.1, 0, 0.3, 0, 0, 0, 0) == pytest.approx(0.4) + + +def test_from_input_1d_function_infers_mach(): + f = Function(lambda mach: mach**2, "Mach", "cD") + coeff = AeroCoefficient(f, name="cD") + assert coeff.depends_on == ("mach",) + assert coeff(0, 0, 0.5, 0, 0, 0, 0) == pytest.approx(0.25) + + +def test_from_input_function_with_bad_dimension_raises(): + f = Function(lambda a, b: a + b, ["alpha", "beta"], "cL") + with pytest.raises(ValueError, match="must have 7 input arguments"): + AeroCoefficient(f, name="cL") + + +# -- constructor inference: CSV path ----------------------------------------------------- + + +def test_from_input_csv_loads_at_minimal_dimension(tmp_path): + csv_file = tmp_path / "coeffs.csv" + csv_file.write_text("mach,cD\n0.0,0.0\n1.0,3.0\n2.0,6.0\n") + + coeff = AeroCoefficient(str(csv_file), name="cD") + assert coeff.depends_on == ("mach",) + assert coeff(0, 0, 2, 0, 0, 0, 0) == pytest.approx(6) + + +def test_load_csv_rejects_unknown_column(tmp_path): + csv_file = tmp_path / "coeffs.csv" + csv_file.write_text("bogus,cD\n0.0,0.0\n1.0,3.0\n") + + with pytest.raises(ValueError, match="Invalid independent variable"): + AeroCoefficient(str(csv_file), name="cD") + + +# -- constructor inference: AeroCoefficient round trip ----------------------------------- + + +def test_roundtrip_callable_passthrough(): + original = AeroCoefficient(lambda alpha, mach: alpha + mach, name="cL") + rebuilt = AeroCoefficient(original, name="cL") + assert rebuilt.depends_on == original.depends_on + assert rebuilt(0.5, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + original(0.5, 0, 0.3, 0, 0, 0, 0) + ) + + +def test_roundtrip_constant_passthrough(): + original = AeroCoefficient(0.9, name="cD") + rebuilt = AeroCoefficient(original, name="cD") + assert rebuilt._constant == pytest.approx(0.9) + assert rebuilt(1, 2, 3, 4, 5, 6, 7) == pytest.approx(0.9) + + +def test_to_dict_from_dict_preserves_axes(): + original = AeroCoefficient( + lambda deflection: deflection, + ("deflection",), + unsteady_aero=True, + control_variables=("deflection",), + name="cL", + ) + rebuilt = AeroCoefficient.from_dict(original.to_dict()) + assert rebuilt.unsteady_aero is True + assert rebuilt.control_variables == ("deflection",) + assert rebuilt.independent_vars == original.independent_vars + + +# -- _infer_single_var fallbacks ---------------------------------------------- + + +def test_infer_single_var_unmatched_label_defaults_to_first(): + f = Function(lambda gamma: gamma, "gamma", "cD") + assert AeroCoefficient._infer_single_var(f, IV) == IV[0] + + +def test_infer_single_var_missing_inputs_defaults_to_first(): + class NoInputs: + pass + + assert AeroCoefficient._infer_single_var(NoInputs(), IV) == IV[0] diff --git a/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py new file mode 100644 index 000000000..f9828d205 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py @@ -0,0 +1,163 @@ +"""Regression tests for the GenericSurface-rooted aerodynamic hierarchy. + +After the refactor, every aerodynamic surface (Barrowman or generic) is +described by the generic coefficient model and exposes the diagnostic accessors +``lift_coefficient_derivative`` and ``center_of_pressure_z`` used by the rocket's +center-of-pressure / stability-margin computation. These tests pin the +properties that the refactor is meant to guarantee. +""" + +import warnings + +import numpy as np +import pytest + +from rocketpy import LinearGenericSurface, NoseCone, Tail, TrapezoidalFins + + +def test_barrowman_derived_cp_matches_geometric_cp(): + """The derived ``center_of_pressure_z`` must reproduce the geometric cp of + each Barrowman surface (the moment is carried by ``cm`` but the diagnostic + must recover the original location).""" + nose = NoseCone( + length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 + ) + tail = Tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, rocket_radius=0.0635 + ) + fins = TrapezoidalFins( + n=4, span=0.100, root_chord=0.120, tip_chord=0.040, rocket_radius=0.0635 + ) + + for surface in (nose, tail, fins): + for mach in (0.0, 0.5, 0.9): + assert ( + pytest.approx( + surface.center_of_pressure_z.get_value_opt(mach), rel=1e-6, abs=1e-9 + ) + == surface.cpz + ) + # The normal-force slope diagnostic must equal the Barrowman clalpha. + assert pytest.approx( + nose.lift_coefficient_derivative.get_value_opt(0.0) + ) == nose.clalpha.get_value_opt(0.0) + + +def test_generic_surface_contributes_to_static_margin(calisto_motorless): + """A generic surface must now contribute to the rocket center of pressure + (previously generic surfaces were skipped, breaking stability margin).""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + + cp_without_generic = rocket.aerodynamic_center.get_value_opt(0.2) + + # A lifting generic surface placed aft should move the cp aft (more stable). + generic = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cL_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, + }, + name="generic_fins", + ) + rocket.add_surfaces(generic, positions=-1.0) + + cp_with_generic = rocket.aerodynamic_center.get_value_opt(0.2) + assert cp_with_generic != pytest.approx(cp_without_generic) + assert np.isfinite(cp_with_generic) + + +def test_zero_lift_surface_does_not_break_cp(calisto_motorless): + """A surface with no normal-force slope must drop out of the lift-weighted + cp average without producing NaNs.""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + cp_reference = rocket.aerodynamic_center.get_value_opt(0.2) + + drag_only = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={"cD_0": lambda a, b, m, re, p, q, r: 0.5}, + name="drag_only", + ) + rocket.add_surfaces(drag_only, positions=-1.0) + + cp_after = rocket.aerodynamic_center.get_value_opt(0.2) + assert np.isfinite(cp_after) + assert cp_after == pytest.approx(cp_reference) + + +def test_axisymmetric_rocket_pitch_equals_yaw_margin(calisto_motorless): + """An axisymmetric rocket must have identical pitch and yaw margins and + must not raise the asymmetry warning.""" + rocket = calisto_motorless + with warnings.catch_warnings(): + warnings.simplefilter("error") # asymmetry warning would fail the test + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, span=0.100, root_chord=0.120, tip_chord=0.040, position=-1.04 + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194 + ) + + for mach in (0.0, 0.5, 0.9): + assert rocket.aerodynamic_center.get_value_opt(mach) == pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(mach), abs=1e-9 + ) + assert rocket.static_margin.get_value_opt(0) == pytest.approx( + rocket.static_margin_yaw.get_value_opt(0), abs=1e-9 + ) + + +def test_non_axisymmetric_rocket_splits_margins_and_warns(calisto_motorless): + """A non-axisymmetric generic surface must yield distinct pitch/yaw margins + and raise a warning that the scalar margin describes the pitch plane only. + + The advisory is emitted lazily -- on the first evaluation of the aerodynamic + center, not eagerly at add time -- so adding the surface itself is silent.""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + asymmetric = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cL_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, + "cQ_beta": lambda a, b, m, re, p, q, r: -2.0, + "cn_beta": lambda a, b, m, re, p, q, r: 2.0, + }, + name="asym", + ) + + rocket.add_surfaces(asymmetric, positions=-1.0) + + # Warning fires once, on the first aerodynamic-center evaluation. + with pytest.warns(UserWarning, match="not\\s+axisymmetric"): + ac_pitch = rocket.aerodynamic_center.get_value_opt(0.2) + + assert ac_pitch != pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(0.2) + ) + assert rocket.static_margin.get_value_opt(0) != pytest.approx( + rocket.static_margin_yaw.get_value_opt(0) + ) + + +def test_barrowman_surface_uses_generic_compute_path(): + """Barrowman surfaces must route through the shared generic + ``compute_forces_and_moments`` (no bespoke override) and apply their force + at the origin (moment carried by the coefficients).""" + from rocketpy.rocket.aero_surface.generic_surface import GenericSurface + + nose = NoseCone( + length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 + ) + assert isinstance(nose, GenericSurface) + # Force is applied at the origin; the cp offset lives in cm/cn. + assert tuple(nose.force_application_point) == (0, 0, 0) + assert ( + nose.compute_forces_and_moments.__func__ + is GenericSurface.compute_forces_and_moments + ) diff --git a/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py new file mode 100644 index 000000000..837803648 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py @@ -0,0 +1,101 @@ +"""Unit tests for ControllableGenericSurface and the controllable-surface +controller linkage.""" + +import pytest + +from rocketpy import ControllableGenericSurface, Function, GenericSurface +from rocketpy.mathutils.vector_matrix import Vector + +DENSITY = Function(lambda z: 1.16) +VISCOSITY = Function(lambda z: 1.8e-5) + + +def _moment_at_deflection(surface, deflection, comp="pitch"): + surface.set_control("deflection", deflection) + r1, r2, r3, m1, m2, m3 = surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + ) + return {"pitch": m1, "yaw": m2, "roll": m3}[comp] + + +def test_control_variable_extends_independent_vars(): + surface = ControllableGenericSurface( + reference_area=1, reference_length=0.2, coefficients={} + ) + assert surface.independent_vars[:7] == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + assert surface.independent_vars[7:] == ["deflection"] + assert surface.control_state == {"deflection": 0.0} + + +def test_deflection_produces_proportional_control_moment(): + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cm": lambda a, b, m, re, p, q, r, deflection: 0.5 * deflection}, + ) + m0 = _moment_at_deflection(surface, 0.0) + m1 = _moment_at_deflection(surface, 0.1) + m2 = _moment_at_deflection(surface, 0.2) + assert m0 == pytest.approx(0.0) + assert m2 == pytest.approx(2 * m1) + assert m1 != pytest.approx(0.0) + + +def test_multiple_named_controls(): + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cn": lambda a, b, m, re, p, q, r, dp, dy: 0.3 * dy}, + controls=("delta_pitch", "delta_yaw"), + ) + assert surface.independent_vars[7:] == ["delta_pitch", "delta_yaw"] + surface.set_control("delta_yaw", 0.5) + yaw = surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + )[4] + assert yaw != pytest.approx(0.0) + + +def test_set_control_unknown_name_raises(): + surface = ControllableGenericSurface( + reference_area=1, reference_length=0.2, coefficients={} + ) + with pytest.raises(KeyError): + surface.set_control("not_a_control", 0.1) + + +def test_plain_generic_surface_default_independent_vars_unchanged(): + surface = GenericSurface(reference_area=1, reference_length=0.2, coefficients={}) + assert surface.independent_vars == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] diff --git a/tests/unit/rocket/aero_surface/test_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_generic_surfaces.py index c16a1b592..43543a50e 100644 --- a/tests/unit/rocket/aero_surface/test_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_generic_surfaces.py @@ -89,12 +89,10 @@ def test_csv_independent_variables_accept_any_order(tmp_path): coefficients={"cL": str(filename)}, ) - closure = generic_surface.cL.source.__closure__ - csv_function = next( - cell.cell_contents - for cell in closure - if isinstance(cell.cell_contents, Function) - ) + # The coefficient is stored at minimal dimension over its CSV columns, in + # header order; AeroCoefficient maps the full argument tuple onto them. + assert generic_surface.cL.depends_on == ("mach", "alpha") + csv_function = generic_surface.cL.function assert generic_surface.cL(1, 0, 2, 0, 0, 0, 0) == pytest.approx(12) assert csv_function.get_interpolation_method() == "regular_grid" @@ -117,3 +115,32 @@ def test_compute_forces_and_moments(): z=0, ) assert forces_and_moments == (0, 0, 0, 0, 0, 0) + + +def test_angular_rates_are_non_dimensionalized(): + """Coefficients receive the conventional reduced rate q* = q L_ref / (2 V), + not the raw body rate in rad/s.""" + ref_area, ref_length = 2.0, 0.5 + # Roll-moment coefficient that simply returns the roll rate it is given, so + # the resulting roll moment exposes which rate value reached the coefficient. + gs = GenericSurface(ref_area, ref_length, {"cl": lambda roll_rate: roll_rate}) + + rho, speed, raw_roll = 1.2, 10.0, 4.0 + *_, roll_moment = gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # along centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, raw_roll), # raw body roll rate p, rad/s + density=Function(1.0), + dynamic_viscosity=Function(1.0), + z=0, + ) + + reduced_roll = raw_roll * ref_length / (2 * speed) + dyn_pressure_area_length = 0.5 * rho * speed**2 * ref_area * ref_length + # The coefficient saw the reduced rate, ... + assert roll_moment == pytest.approx(dyn_pressure_area_length * reduced_roll) + # ... not the raw rad/s rate. + assert roll_moment != pytest.approx(dyn_pressure_area_length * raw_roll) diff --git a/tests/unit/rocket/aero_surface/test_individual_fins.py b/tests/unit/rocket/aero_surface/test_individual_fins.py index d232e0772..6db540a8a 100644 --- a/tests/unit/rocket/aero_surface/test_individual_fins.py +++ b/tests/unit/rocket/aero_surface/test_individual_fins.py @@ -7,7 +7,9 @@ from rocketpy import ( EllipticalFin, + EllipticalFins, FreeFormFin, + FreeFormFins, Rocket, TrapezoidalFin, TrapezoidalFins, @@ -375,16 +377,124 @@ def test_calisto_finset_vs_four_individual_fins_close(): mach_grid = np.linspace(0, 2, 21) # Act - cp_finset = finset_rocket.cp_position(mach_grid) - cp_individual = individual_fins_rocket.cp_position(mach_grid) + cp_finset = finset_rocket.aerodynamic_center(mach_grid) + cp_individual = individual_fins_rocket.aerodynamic_center(mach_grid) clalpha_finset = finset_rocket.total_lift_coeff_der(mach_grid) clalpha_individual = individual_fins_rocket.total_lift_coeff_der(mach_grid) - lift_correction = TrapezoidalFins.fin_num_correction(4) / 4 - clalpha_individual_corrected = np.array(clalpha_individual) * lift_correction - # Assert + # Assert. Each individual fin projects its lift slope onto the pitch plane by + # sin(phi)**2, so an evenly spaced set of 4 sums to fin_num_correction(4) = 2 + # in the plane -- matching the fin set directly, with no extra correction. np.testing.assert_allclose(cp_individual, cp_finset, rtol=1e-6, atol=1e-6) - np.testing.assert_allclose(clalpha_individual_corrected, clalpha_finset) + np.testing.assert_allclose(clalpha_individual, clalpha_finset) + + +@pytest.mark.parametrize( + "fin_cls, geometry", + [ + ( + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + (EllipticalFin, dict(root_chord=0.120, span=0.100, rocket_radius=0.0635)), + ( + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_canted_individual_fin_builds_and_places(fin_cls, geometry): + """A canted individual fin of any shape must build its body<->fin rotation + matrices at construction, so it can be placed on a rocket. Regression test + for a crash where non-trapezoidal individual fins lacked + ``_rotation_fin_to_body_uncanted`` and failed in + ``_compute_leading_edge_position``.""" + fin = fin_cls(angular_position=30, cant_angle=2.0, **geometry) + assert hasattr(fin, "_rotation_fin_to_body_uncanted") + position = fin._compute_leading_edge_position(-1.168, 1) + assert position is not None + + +@pytest.mark.parametrize( + "fin_cls, geometry", + [ + ( + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + (EllipticalFin, dict(root_chord=0.120, span=0.100, rocket_radius=0.0635)), + ( + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_individual_fin_roll_moment_independent_of_angular_position(fin_cls, geometry): + """A canted individual fin's roll moment must be the same at any angular + position (rotational symmetry about the roll axis). Regression test for a bug + where the fin's center of pressure was not rotated to its azimuth (the + rotation matrix was left as the identity), making the roll moment vary with + angular position.""" + stream_velocity = Vector([0, 0, -1.0]) + omega = Vector([0, 0, 0]) + + roll_moments = [] + for angle in (0, 90, 180, 270): + rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + fin = fin_cls(angular_position=angle, cant_angle=2.0, **geometry) + rocket.add_surfaces(fin, -1.168) + cp = rocket.surfaces_cp_to_cdm[fin] + roll = fin.compute_forces_and_moments( + stream_velocity, 1.0, 0.3, 1.0, cp, omega + )[5] + roll_moments.append(roll) + + np.testing.assert_allclose(roll_moments, roll_moments[0], rtol=1e-9) + assert abs(roll_moments[0]) > 0 + + +@pytest.mark.parametrize( + "set_cls, fin_cls, geometry", + [ + ( + TrapezoidalFins, + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + ( + EllipticalFins, + EllipticalFin, + dict(root_chord=0.120, span=0.100, rocket_radius=0.0635), + ), + ( + FreeFormFins, + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_finset_roll_forcing_equals_n_single_fins(set_cls, fin_cls, geometry): + """A fin set's roll forcing coefficient must scale with the full fin count + ``n`` (every identically-canted fin adds the same roll moment), so it equals + ``n`` times a single fin's roll forcing -- for every fin shape. Regression + test for a bug where the set used the normal-force ``fin_num_correction(n)`` + (~n/2), halving the roll forcing (and roll rate) of a fin set.""" + n = 4 + finset = set_cls(n=n, cant_angle=2.0, **geometry) + single = fin_cls(angular_position=0, cant_angle=2.0, **geometry) + + mach_grid = np.linspace(0, 2, 11) + clf_finset = finset.roll_parameters[0](mach_grid) + clf_single = single.roll_parameters[0](mach_grid) + np.testing.assert_allclose(clf_finset, n * np.array(clf_single), rtol=1e-6) @pytest.mark.parametrize( diff --git a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py index 4f0695143..88d7973cb 100644 --- a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py @@ -91,3 +91,33 @@ def test_compute_forces_and_moments(): z=0, ) assert forces_and_moments == (0, 0, 0, 0, 0, 0) + + +def test_roll_damping_uses_reduced_rate(): + """The roll-damping derivative cl_p is applied to the reduced roll rate + p* = p L_ref / (2 V). This must equal the previous raw-rate scaling + (0.5 rho V A L^2 / 2) * cl_p * p, confirming the change is result-identical + for the linear model.""" + ref_area, ref_length, cl_p = 2.0, 0.5, 3.0 + lgs = LinearGenericSurface(ref_area, ref_length, {"cl_p": cl_p}) + + rho, speed, raw_roll = 1.2, 10.0, 4.0 + *_, roll_moment = lgs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # along centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, raw_roll), # raw body roll rate p, rad/s + density=Function(1.0), + dynamic_viscosity=Function(1.0), + z=0, + ) + + reduced_roll = raw_roll * ref_length / (2 * speed) + dyn_pressure_area_length = 0.5 * rho * speed**2 * ref_area * ref_length + # New (reduced-rate) formulation: + assert roll_moment == pytest.approx(dyn_pressure_area_length * cl_p * reduced_roll) + # Old (raw-rate) formulation -- identical value: + old_damping_scaling = 0.5 * rho * speed * ref_area * ref_length**2 / 2 + assert roll_moment == pytest.approx(old_damping_scaling * cl_p * raw_roll) diff --git a/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py b/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py new file mode 100644 index 000000000..893f90faf --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py @@ -0,0 +1,71 @@ +"""Unit tests for the optional alpha_dot/beta_dot unsteady coefficient axes of +GenericSurface.""" + +import pytest + +from rocketpy import Function, GenericSurface +from rocketpy.mathutils.vector_matrix import Vector + +DENSITY = Function(lambda z: 1.16) +VISCOSITY = Function(lambda z: 1.8e-5) + + +def _pitch_moment(surface, alpha_dot): + return surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + alpha_dot=alpha_dot, + beta_dot=0.0, + )[3] + + +def test_unsteady_axes_extend_independent_vars(): + surface = GenericSurface( + reference_area=1, reference_length=0.2, coefficients={}, unsteady_aero=True + ) + assert surface.independent_vars[7:] == ["alpha_dot", "beta_dot"] + + +def test_prescribed_alpha_dot_produces_unsteady_pitch_moment(): + surface = GenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={ + "cm": lambda a, b, m, re, p, q, r, alpha_dot, beta_dot: 0.7 * alpha_dot + }, + unsteady_aero=True, + ) + m0 = _pitch_moment(surface, 0.0) + m1 = _pitch_moment(surface, 0.5) + m2 = _pitch_moment(surface, 1.0) + assert m0 == pytest.approx(0.0) + assert m1 != pytest.approx(0.0) + assert m2 == pytest.approx(2 * m1) + + +def test_default_surface_ignores_alpha_dot_and_stays_seven_var(): + """Existing 7-variable surfaces must be unaffected: independent vars + unchanged and alpha_dot/beta_dot ignored at evaluation.""" + surface = GenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cm": lambda a, b, m, re, p, q, r: 0.1}, + ) + assert surface.independent_vars == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + # passing nonzero alpha_dot must not change the result + assert _pitch_moment(surface, 0.0) == pytest.approx(_pitch_moment(surface, 99.0)) diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 3c7725fa5..623eebf1a 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -34,7 +34,7 @@ def test_evaluate_static_margin_assert_cp_equals_cm(dimensionless_calisto): rocket.center_of_mass(burn_time[1]) / (2 * rocket.radius), 1e-8 ) == pytest.approx(rocket.static_margin(burn_time[1]), 1e-8) assert pytest.approx(rocket.total_lift_coeff_der(0), 1e-8) == pytest.approx(0, 1e-8) - assert pytest.approx(rocket.cp_position(0), 1e-8) == pytest.approx(0, 1e-8) + assert pytest.approx(rocket.aerodynamic_center(0), 1e-8) == pytest.approx(0, 1e-8) @pytest.mark.parametrize( @@ -53,7 +53,7 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calist assert static_margin_final == pytest.approx(calisto.static_margin(np.inf), 1e-8) assert clalpha == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) - assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) + assert calisto.aerodynamic_center(0) == pytest.approx(cpz, 1e-8) dimensionless_calisto.add_nose(length=0.55829 * m, kind=type_, position=(1.160) * m) assert pytest.approx(dimensionless_calisto.static_margin(0), 1e-8) == pytest.approx( @@ -66,8 +66,8 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calist dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): @@ -91,7 +91,7 @@ def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): assert np.abs(clalpha) == pytest.approx( np.abs(calisto.total_lift_coeff_der(0)), 1e-8 ) - assert calisto.cp_position(0) == cpz + assert calisto.aerodynamic_center(0) == cpz dimensionless_calisto.add_tail( top_radius=0.0635 * m, @@ -109,8 +109,8 @@ def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) @pytest.mark.parametrize( @@ -146,7 +146,9 @@ def test_add_trapezoidal_fins_sweep_angle( assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) - assert translate - calisto.cp_position(0) == pytest.approx(expected_cpz_cm, 0.01) + assert translate - calisto.aerodynamic_center(0) == pytest.approx( + expected_cpz_cm, 0.01 + ) @pytest.mark.parametrize( @@ -186,7 +188,9 @@ def test_add_trapezoidal_fins_sweep_length( assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) - assert translate - calisto.cp_position(0) == pytest.approx(expected_cpz_cm, 0.01) + assert translate - calisto.aerodynamic_center(0) == pytest.approx( + expected_cpz_cm, 0.01 + ) assert isinstance(calisto.aerodynamic_surfaces[0].component, NoseCone) @@ -223,7 +227,7 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): assert np.abs(clalpha) == pytest.approx( np.abs(calisto.total_lift_coeff_der(0)), 1e-8 ) - assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) + assert calisto.aerodynamic_center(0) == pytest.approx(cpz, 1e-8) dimensionless_calisto.add_trapezoidal_fins( 4, @@ -242,8 +246,8 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) @pytest.mark.parametrize( @@ -732,22 +736,16 @@ def test_drag_csv_header_order_independent_for_multivariable_input(tmp_path): drag_ordered = rocket_ordered.power_off_drag_7d(0, 0, 0.8, 0.15, 0, 0, 0) drag_swapped = rocket_swapped.power_off_drag_7d(0, 0, 0.8, 0.15, 0, 0, 0) - ordered_closure = rocket_ordered.power_off_drag_7d.source.__closure__ - swapped_closure = rocket_swapped.power_off_drag_7d.source.__closure__ - ordered_csv_function = next( - cell.cell_contents - for cell in ordered_closure - if isinstance(cell.cell_contents, Function) - ) - swapped_csv_function = next( - cell.cell_contents - for cell in swapped_closure - if isinstance(cell.cell_contents, Function) - ) + # The coefficient is stored at minimal dimension over the present columns, + # keyed by name, so column order in the header does not matter. + ordered_csv_function = rocket_ordered.power_off_drag_7d.function + swapped_csv_function = rocket_swapped.power_off_drag_7d.function assert drag_ordered == pytest.approx(0.95) assert drag_swapped == pytest.approx(0.95) assert drag_swapped == pytest.approx(drag_ordered) + assert set(rocket_ordered.power_off_drag_7d.depends_on) == {"mach", "reynolds"} + assert set(rocket_swapped.power_off_drag_7d.depends_on) == {"mach", "reynolds"} assert ordered_csv_function.get_interpolation_method() == "regular_grid" assert swapped_csv_function.get_interpolation_method() == "regular_grid" diff --git a/tests/unit/rocket/test_stability_rework.py b/tests/unit/rocket/test_stability_rework.py new file mode 100644 index 000000000..e911b9feb --- /dev/null +++ b/tests/unit/rocket/test_stability_rework.py @@ -0,0 +1,93 @@ +"""Tests for the reworked stability model: the aerodynamic center, the +cp_position alias, the reconstructed nonlinear center of pressure, and the +aggregate aerodynamic coefficients.""" + +import numpy as np +import pytest + + +def test_cp_position_alias_matches_aerodynamic_center(calisto_robust): + """``cp_position`` is a plain alias of ``aerodynamic_center`` (no warning).""" + rocket = calisto_robust + assert rocket.cp_position.get_value_opt(0.3) == pytest.approx( + rocket.aerodynamic_center.get_value_opt(0.3) + ) + + +def test_reconstructed_center_of_pressure_converges_to_aerodynamic_center( + calisto_robust, +): + """The nonlinear center of pressure, reconstructed from the aggregate + coefficients as ``x_cdm + csys * d * Cm / CN``, converges to the linear + aerodynamic center as the angle of attack goes to zero. (The singular + nonlinear CP is no longer a blessed method; this is its documented + reconstruction path.)""" + rocket = calisto_robust + mach = 0.3 + aerodynamic_center = rocket.aerodynamic_center.get_value_opt(mach) + csys = rocket._csys + diameter = 2 * rocket.radius + cdm = rocket.center_of_dry_mass_position + + coeffs = rocket.aerodynamic_coefficients_full(np.radians(0.1), 0.0, mach) + reconstructed_cp = cdm + csys * diameter * coeffs["cm"] / coeffs["cL"] + assert reconstructed_cp == pytest.approx(aerodynamic_center, abs=1e-3) + + +def test_aerodynamic_coefficients_normal_force_grows_with_alpha(calisto_robust): + """Total normal-force coefficient increases with angle of attack and is zero + at zero incidence; the returned dict exposes normal force and pitch moment.""" + rocket = calisto_robust + coeffs = rocket.aerodynamic_coefficients(np.radians(5), 0.0, 0.3) + assert set(coeffs) == {"normal_force", "pitch_moment"} + + cn_2 = rocket.aerodynamic_coefficients(np.radians(2), 0.0, 0.3)["normal_force"] + cn_8 = rocket.aerodynamic_coefficients(np.radians(8), 0.0, 0.3)["normal_force"] + assert cn_8 > cn_2 > 0 + + +def test_axisymmetric_rocket_planes_coincide(calisto_robust): + """An axisymmetric rocket has matching pitch and yaw aerodynamic centers.""" + rocket = calisto_robust + assert rocket.is_axisymmetric + for mach in (0.0, 0.5, 1.0): + assert rocket.aerodynamic_center.get_value_opt(mach) == pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(mach) + ) + + +def test_aerodynamic_coefficients_full_signed_set(calisto_robust): + """The full rocket coefficient set returns all six signed coefficients; + lift grows with alpha, drag comes from the vehicle drag curve, and the pitch + moment is restoring (negative) for a stable rocket.""" + rocket = calisto_robust + coeffs = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3) + assert set(coeffs) == {"cL", "cQ", "cD", "cm", "cn", "cl"} + + low = rocket.aerodynamic_coefficients_full(np.radians(2), 0.0, 0.3) + assert coeffs["cL"] > low["cL"] > 0 + assert coeffs["cm"] < 0 # restoring pitch moment about the center of dry mass + assert coeffs["cD"] == pytest.approx( + rocket.power_off_drag_by_mach.get_value_opt(0.3) + ) + + +def test_add_vehicle_aerodynamic_surface(calisto_robust): + """A supplied full-vehicle coefficient set is added as a single generic + surface and contributes to the rocket aggregate (rocket-as-GenericSurface).""" + rocket = calisto_robust + base_cl = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cL"] + n_before = len(rocket.aerodynamic_surfaces) + + surface = rocket.add_vehicle_aerodynamic_surface( + coefficients={"cL": lambda a, b, m, re, p, q, r: 2.0 * a} + ) + + assert len(rocket.aerodynamic_surfaces) == n_before + 1 + # The vehicle surface exposes the uniform coefficient accessors. + assert surface.cL(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( + 2.0 * np.radians(5) + ) + # Its lift adds to the rocket aggregate. + new_cl = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cL"] + assert new_cl > base_cl diff --git a/tests/unit/simulation/test_event_scheduler.py b/tests/unit/simulation/test_event_scheduler.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py index eacd2f3e1..0d42ee3a3 100644 --- a/tests/unit/simulation/test_flight.py +++ b/tests/unit/simulation/test_flight.py @@ -240,8 +240,8 @@ def test_export_sensor_data(flight_calisto_with_sensors): @pytest.mark.parametrize( "flight_time, expected_values", [ - ("t_initial", (0.25886, -0.649623, 0)), - ("out_of_rail_time", (0.792028, -1.987634, 0)), + ("t_initial", (-0.256474, -0.221748, 0)), + ("out_of_rail_time", (0.780787, -1.967135, 0)), ("apogee_time", (-0.509420, -0.732933, -2.089120e-14)), ("t_final", (0, 0, 0)), ], @@ -279,9 +279,9 @@ def test_aerodynamic_moments(flight_calisto_custom_wind, flight_time, expected_v @pytest.mark.parametrize( "flight_time, expected_values", [ - ("t_initial", (1.654150, 0.659142, -0.067103)), - ("out_of_rail_time", (5.052628, 2.013361, -1.75370)), - ("apogee_time", (2.321838, -1.613641, -0.962108)), + ("t_initial", (-0.062135, -1.936030, 1.612160)), + ("out_of_rail_time", (4.968766, 1.957238, -0.629070)), + ("apogee_time", (2.343357, -1.606424, -0.377026)), ("t_final", (-0.019802, 0.012030, 159.051604)), ], ) From b0c523305e9fc733c765e7a25ceeb82e55339815 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 10 Jul 2026 19:15:33 -0300 Subject: [PATCH 4/8] MNT: polish event and flight-phase helper docstrings Co-Authored-By: Claude Opus 4.8 --- rocketpy/simulation/events/event.py | 26 ++++++++------- rocketpy/simulation/events/event_builders.py | 2 +- rocketpy/simulation/helpers/event_calling.py | 33 +++++++++++++++++++ rocketpy/simulation/helpers/event_commands.py | 25 +++++++++----- rocketpy/simulation/helpers/flight_phase.py | 3 ++ 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/rocketpy/simulation/events/event.py b/rocketpy/simulation/events/event.py index bfcd1051d..4029653a9 100644 --- a/rocketpy/simulation/events/event.py +++ b/rocketpy/simulation/events/event.py @@ -25,7 +25,7 @@ class Event: - """Event helper with trigger/callback execution and exact-time support. + """A rule that runs an action during a flight when a condition is met. An ``Event`` is the main way RocketPy reacts to conditions during a flight. It pairs a ``trigger`` predicate with a ``callback`` action: at @@ -147,11 +147,11 @@ def __init__( # pylint: disable=too-many-arguments slower with no gain in accuracy. Automatically forced to ``False`` when ``sampling_rate`` is ``None``. changes_dynamics : bool, optional - Set to ``True`` when the callback changes the simulation dynamics or - any parameter affecting the ODE derivative. This includes mutating an - attribute of any simulation object, and using the - ``set_derivative``, ``start_flight_phase``, or ``terminate_flight`` - commands. Defaults to ``False``. + Set to ``True`` when the callback changes anything that affects the + equations of motion. This includes changing an attribute of any + simulation object, and using the ``set_derivative``, + ``start_flight_phase``, or ``terminate_flight`` commands. Defaults to + ``False``. name : str, optional Human-readable identifier used in logs and debugging. Defaults to ``"Custom Event"``. @@ -174,12 +174,11 @@ def __init__( # pylint: disable=too-many-arguments - 3: Controller events - 4: Custom / user-defined events (default) needs : list of str or None, optional - Declares which expensive simulation values the event's trigger and - callback actually access. Valid keys are ``'state_dot'``, - ``'pressure'``, and ``'state_history'``. The default``None`` is - treated as an empty set and no expensive kwargs are computed. - Supply a list with the keys this event accesses so the runtime - computes them. + Which of the slower-to-compute simulation values the event's trigger + and callback actually use, so the rest are skipped. Valid keys are + ``'state_dot'``, ``'pressure'`` and ``'state_history'``. The default + ``None`` means none of them are computed. List the keys your event + uses to have them provided in ``kwargs``. See Also -------- @@ -306,6 +305,9 @@ def __call__(self, trigger_only=False, callback_only=False, reset=True, **kwargs If True, only execute the callback without evaluating the trigger condition. The exact time function and disable_on function are also called. + reset : bool, optional + If True (default), reset the event's queued commands (via + ``_reset_commands``) before evaluating the trigger. kwargs : dict Keyword arguments passed to the trigger and callback functions. diff --git a/rocketpy/simulation/events/event_builders.py b/rocketpy/simulation/events/event_builders.py index 6eeb56248..0e170255f 100644 --- a/rocketpy/simulation/events/event_builders.py +++ b/rocketpy/simulation/events/event_builders.py @@ -160,7 +160,7 @@ def apogee_event_exact_time_function(state, **_kwargs): ---------- state : array_like Interpolated flight state vector without time. - **kwargs : dict + **_kwargs : dict Event context (unused here). Returns diff --git a/rocketpy/simulation/helpers/event_calling.py b/rocketpy/simulation/helpers/event_calling.py index 2af5abc71..5bbb2601e 100644 --- a/rocketpy/simulation/helpers/event_calling.py +++ b/rocketpy/simulation/helpers/event_calling.py @@ -30,11 +30,29 @@ def build_event_kwargs( Parameters ---------- + flight : Flight + Flight instance whose rocket, environment and sensors are exposed. + time : float + Current simulation time. + state : array_like + Current flight state vector. + step_size : float + Size of the current integration step. + phase : FlightPhase + Active flight phase, providing the state derivative. + rollback : bool, optional + Whether this call happens during a rollback; shifts the state-history + window by one extra step. Defaults to False. needs : frozenset of str, optional Union of ``Event.needs`` across all events that will consume the returned dict. Only keys present in ``needs`` are computed for the expensive values: ``state_dot``, ``pressure``, ``state_history``. Defaults to empty (compute nothing expensive). + + Returns + ------- + dict + Keyword arguments consumed by event triggers and callbacks. """ kwargs = { "time": time, @@ -72,10 +90,25 @@ def update_overshootable_event_kwargs( Parameters ---------- + flight : Flight + Flight instance whose environment is used to recompute derived values. + phase : FlightPhase + Active flight phase, providing the state derivative. + event_kwargs : dict + Kwargs dict (from :func:`build_event_kwargs`) updated in place. + interpolated_time : float + Interpolated time of the overshootable node. + interpolated_state : array_like + Interpolated flight state at the node. needs : frozenset of str, optional Union of ``Event.needs`` across all overshootable events at this node. Expensive values are skipped when absent from ``needs``. Defaults to empty (compute nothing expensive). + + Returns + ------- + dict + The updated ``event_kwargs``. """ event_kwargs["time"] = interpolated_time event_kwargs["state"] = interpolated_state diff --git a/rocketpy/simulation/helpers/event_commands.py b/rocketpy/simulation/helpers/event_commands.py index 348e5add5..7d3391eb4 100644 --- a/rocketpy/simulation/helpers/event_commands.py +++ b/rocketpy/simulation/helpers/event_commands.py @@ -28,6 +28,9 @@ def apply_event_commands( Index of the current flight phase. node_index : int Index of the current time node. + command_time : float + Simulation time used to apply the commands when the event does not + provide an exact time. Returns ------- @@ -70,10 +73,6 @@ def apply_rollback_command(flight, time, state): ---------- flight : Flight Flight instance being updated. - event_results : dict - Result payload returned by the event system. - phase : _FlightPhase - Current flight phase. time : float Interpolated simulation time to restore. state : array_like @@ -94,6 +93,8 @@ def apply_disable_commands(_, event_results, node_index, event, phase, time): Parameters ---------- + _ : Flight + Flight instance (unused; accepted for a uniform command signature). event_results : dict Result payload returned by the event system. node_index : int @@ -102,6 +103,8 @@ def apply_disable_commands(_, event_results, node_index, event, phase, time): Event currently being processed. phase : _FlightPhase Current flight phase. + time : float + Simulation time at which the events are disabled. Returns ------- @@ -144,6 +147,8 @@ def apply_enable_commands(flight, event_results, node_index, event, phase, time) Parameters ---------- + flight : Flight + Flight instance being updated. event_results : dict Result payload returned by the event system. node_index : int @@ -152,6 +157,8 @@ def apply_enable_commands(flight, event_results, node_index, event, phase, time) Event currently being processed. phase : _FlightPhase Current flight phase. + time : float + Simulation time at which the events are enabled. Returns ------- @@ -260,6 +267,8 @@ def apply_new_phase_or_derivative( Index of the current flight phase. node_index : int Index of the current time node. + time : float + Simulation time at which the new phase or derivative takes effect. Returns ------- @@ -317,6 +326,8 @@ def apply_termination(flight, event_results, phase, phase_index, node_index, tim Index of the current flight phase. node_index : int Index of the current time node. + time : float + Simulation time at which the flight is terminated. Returns ------- @@ -358,12 +369,10 @@ def apply_event_list_updates(flight, event_results, phase, time): Flight instance being updated. event_results : dict Result payload returned by the event system. - node_index : int - Index of the current time node. - event : Event - Event currently being processed. phase : _FlightPhase Current flight phase. + time : float + Simulation time at which the new events are scheduled. Returns ------- diff --git a/rocketpy/simulation/helpers/flight_phase.py b/rocketpy/simulation/helpers/flight_phase.py index fbdd0b746..1a9d3642a 100644 --- a/rocketpy/simulation/helpers/flight_phase.py +++ b/rocketpy/simulation/helpers/flight_phase.py @@ -276,6 +276,9 @@ def add_phase( name : str, optional A descriptive name to identify the phase in logs and debug output. Default is None. + **kwargs + Additional keyword arguments forwarded to the ``_FlightPhase`` + constructor (e.g. ``parachute``). Returns ------- From 63cad27b3f69e44fe64f0da06858f150be0c5a43 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 10 Jul 2026 19:15:57 -0300 Subject: [PATCH 5/8] 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 --- rocketpy/mathutils/function.py | 131 ++++- rocketpy/plots/aero_surface_plots.py | 13 +- rocketpy/plots/rocket_plots.py | 4 +- .../rocket/aero_surface/_barrowman_surface.py | 199 +++++-- .../rocket/aero_surface/aero_coefficient.py | 412 ++++++++------ rocketpy/rocket/aero_surface/air_brakes.py | 3 +- .../controllable_generic_surface.py | 64 ++- .../rocket/aero_surface/fins/_base_fin.py | 6 +- .../aero_surface/fins/elliptical_fin.py | 9 +- .../aero_surface/fins/elliptical_fins.py | 9 +- rocketpy/rocket/aero_surface/fins/fin.py | 99 ++-- rocketpy/rocket/aero_surface/fins/fins.py | 30 +- .../rocket/aero_surface/fins/free_form_fin.py | 9 +- .../aero_surface/fins/free_form_fins.py | 9 +- .../aero_surface/fins/trapezoidal_fins.py | 9 +- .../rocket/aero_surface/generic_surface.py | 504 +++++++++++++----- .../aero_surface/linear_generic_surface.py | 277 ++++++---- rocketpy/rocket/aero_surface/nose_cone.py | 21 +- rocketpy/rocket/aero_surface/tail.py | 20 +- rocketpy/rocket/rocket.py | 172 +++--- rocketpy/simulation/flight.py | 35 +- .../linear_generic_surfaces_fixtures.py | 4 +- tests/unit/mathutils/test_function.py | 87 +++ .../test_barrowman_generic_equivalence.py | 49 +- .../aero_surface/test_generic_surfaces.py | 153 +++++- .../test_linear_generic_surfaces.py | 22 +- .../test_surface_coefficient_completeness.py | 206 +++++++ tests/unit/rocket/test_rocket.py | 4 +- tests/unit/rocket/test_stability_rework.py | 27 +- tests/unit/simulation/test_flight.py | 10 +- 30 files changed, 1773 insertions(+), 824 deletions(-) create mode 100644 tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 33a82ec01..2787f569e 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -40,6 +40,32 @@ "regular_grid": 6, } EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2} +# Maps a requested interpolation name onto a scipy ``RegularGridInterpolator`` +# ``method`` for gridded (N-D Cartesian) data. The 1-D-only names ``spline`` and +# ``akima`` fall back to their closest grid analogs (``cubic`` and the +# shape-preserving ``pchip``); anything unrecognized defaults to ``linear``. +REGULAR_GRID_METHODS = { + "linear": "linear", + "nearest": "nearest", + "slinear": "slinear", + "cubic": "cubic", + "quintic": "quintic", + "pchip": "pchip", + "spline": "cubic", + "akima": "pchip", + "polynomial": "cubic", +} +# Minimum points per axis required by each ``RegularGridInterpolator`` method. +# A grid with fewer samples on any axis cannot use the higher-order methods, so +# the caller falls back to linear rather than letting SciPy raise mid-build. +REGULAR_GRID_MIN_POINTS = { + "nearest": 1, + "linear": 2, + "slinear": 2, + "pchip": 2, + "cubic": 4, + "quintic": 6, +} class SourceType(Enum): @@ -157,7 +183,12 @@ def __init__( @classmethod def from_regular_grid_csv( - cls, csv_source, variable_names, coeff_name, extrapolation + cls, + csv_source, + variable_names, + coeff_name, + extrapolation, + interpolation="linear", ): """Create a regular-grid Function from CSV samples when possible. @@ -171,6 +202,14 @@ def from_regular_grid_csv( Name of the output coefficient. extrapolation : str Extrapolation method passed to the Function constructor. + interpolation : str, optional + Requested interpolation. Mapped onto a + :class:`scipy.interpolate.RegularGridInterpolator` ``method`` via + :data:`REGULAR_GRID_METHODS` (e.g. ``"spline"`` -> ``"cubic"``, + ``"akima"`` -> ``"pchip"``); unrecognized names fall back to + ``"linear"``. Smooth methods require enough points per axis + (``"cubic"`` needs at least 4), otherwise SciPy raises. Default + ``"linear"``. Returns ------- @@ -215,13 +254,33 @@ def from_regular_grid_csv( return None grid_data = sorted_values.reshape(tuple(axis.size for axis in axes)) - return cls( + grid_function = cls( (axes, grid_data), inputs=variable_names, outputs=[coeff_name], interpolation="regular_grid", extrapolation=extrapolation, ) + # Honor the requested interpolation on the grid by rebuilding the + # interpolator/extrapolator with the mapped scipy ``method``. The + # constructor above always builds the default ("linear"); only rebuild + # when a different method was asked for. + grid_method = REGULAR_GRID_METHODS.get(interpolation, "linear") + smallest_axis = min(axis.size for axis in axes) + if smallest_axis < REGULAR_GRID_MIN_POINTS.get(grid_method, 2): + warnings.warn( + f"Grid interpolation method '{grid_method}' needs at least " + f"{REGULAR_GRID_MIN_POINTS[grid_method]} points per axis, but the " + f"coarsest axis of '{coeff_name}' has {smallest_axis}; falling " + "back to 'linear'.", + UserWarning, + ) + grid_method = "linear" + if grid_method != "linear": + grid_function._grid_method = grid_method + grid_function.set_interpolation("regular_grid") + grid_function.set_extrapolation(grid_function.get_extrapolation_method()) + return grid_function # Define all set methods def set_inputs(self, inputs): @@ -318,6 +377,10 @@ def set_source(self, source): # pylint: disable=too-many-statements self.__dom_dim__ = source.shape[1] - 1 self._domain = source[:, :-1] self._image = source[:, -1] + # Cache per-dimension domain bounds so the N-D hot evaluation path + # (``__get_value_opt_nd``) does not recompute them on every call. + self._domain_min = self._domain.min(axis=0) + self._domain_max = self._domain.max(axis=0) # set x and y. If Function is 2D, also set z if self.__dom_dim__ == 1: @@ -488,11 +551,20 @@ def __process_grid_source(self, source): f"{grid_data.shape[i]} points." ) if not np.all(np.diff(ax) > 0): - warnings.warn( - f"Axis {i} is not strictly sorted in ascending order. " - "RegularGridInterpolator requires sorted axes.", - UserWarning, - ) + # RegularGridInterpolator requires strictly ascending axes. Sort + # this axis (and reorder the grid data along it) so descending or + # shuffled inputs are accepted; repeated coordinates cannot form + # a regular grid and are rejected with a clear error rather than + # a cryptic SciPy failure. + order = np.argsort(ax, kind="stable") + ax = ax[order] + grid_data = np.take(grid_data, order, axis=i) + axes[i] = ax + if not np.all(np.diff(ax) > 0): + raise ValueError( + f"Axis {i} has repeated coordinates; a regular grid " + "requires strictly increasing values along each axis." + ) self._grid_axes = axes self._grid_data = grid_data @@ -596,7 +668,7 @@ def rbf_interpolation(x, x_min, x_max, x_data, y_data, coeffs): # pylint: disab grid_interpolator = RegularGridInterpolator( self._grid_axes, self._grid_data, - method="linear", + method=getattr(self, "_grid_method", "linear"), bounds_error=True, ) # Store so extrapolation funcs can reuse it @@ -720,9 +792,9 @@ def natural_extrapolation( # pylint: disable=function-redefined grid_extrapolator = RegularGridInterpolator( self._grid_axes, self._grid_data, - method="linear", + method=getattr(self, "_grid_method", "linear"), bounds_error=False, - fill_value=None, # linear extrapolation beyond edges + fill_value=None, # extrapolation beyond edges ) def natural_extrapolation( # pylint: disable=function-redefined @@ -824,8 +896,15 @@ def __get_value_opt_nd(self, *args): arg_qty = len(args) result = np.empty(arg_qty) - min_domain = self._domain.T.min(axis=1) - max_domain = self._domain.T.max(axis=1) + # Domain bounds are fixed once the source is set, so they are cached in + # ``set_source`` (this hot path runs per integration step); fall back to + # computing them for any Function built without going through it. + min_domain = getattr(self, "_domain_min", None) + if min_domain is None: + min_domain = self._domain.min(axis=0) + max_domain = self._domain.max(axis=0) + else: + max_domain = self._domain_max lower, upper = args < min_domain, args > max_domain 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 else: source = source.__name__ - return { + function_dict = { "source": source, "title": self.title, "inputs": self.__inputs__, @@ -4171,6 +4250,20 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "extrapolation": self.__extrapolation__, } + # A regular-grid Function cannot be rebuilt from its flat scatter + # ``source``; persist the ``(axes, grid_data)`` structure (and the mapped + # scipy method) instead, so it round-trips through ``from_dict``. + if self.__interpolation__ == "regular_grid": + function_dict["source"] = [ + [np.asarray(axis).tolist() for axis in self._grid_axes], + np.asarray(self._grid_data).tolist(), + ] + grid_method = getattr(self, "_grid_method", "linear") + if grid_method != "linear": + function_dict["grid_method"] = grid_method + + return function_dict + @classmethod def from_dict(cls, func_dict): """Creates a Function instance from a dictionary. @@ -4184,7 +4277,7 @@ def from_dict(cls, func_dict): if func_dict["interpolation"] is None and func_dict["extrapolation"] is None: source = from_hex_decode(source) - return cls( + function = cls( source=source, interpolation=func_dict["interpolation"], extrapolation=func_dict["extrapolation"], @@ -4193,6 +4286,16 @@ def from_dict(cls, func_dict): title=func_dict["title"], ) + # Restore a non-default regular-grid method (the constructor above builds + # the "linear" default); rebuild the interpolator/extrapolator with it. + grid_method = func_dict.get("grid_method") + if grid_method and grid_method != "linear": + function._grid_method = grid_method + function.set_interpolation("regular_grid") + function.set_extrapolation(function.get_extrapolation_method()) + + return function + @staticmethod def __make_arith_lambda( operator, func, other, func_dim, other_dim=0, reverse=False diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index a3753d660..17d2e3a87 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -114,15 +114,14 @@ class _BarrowmanSurfacePlots(_LinearGenericSurfacePlots): geometry drawing and the lift-coefficient surface plot.""" def lift(self): - """Plots the lift coefficient of the aero surface as a function of Mach - and the angle of attack. A 3D plot is expected. See the rocketpy.Function - class for more information on how this plot is made. + """Plots the lift-curve slope (``clalpha``) of the aero surface as a + function of Mach number. Returns ------- None """ - self.aero_surface.cl() + self.aero_surface.clalpha() def all(self): """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 ------- None """ - print("Lift coefficient:") - self.aero_surface.cl(filename=filename) + print("Lift coefficient derivative:") self.aero_surface.clalpha_single_fin(filename=filename) self.aero_surface.clalpha_multiple_fins(filename=filename) @@ -356,8 +354,7 @@ class for more information on how this plot is made. Also, this method ------- None """ - print("Lift coefficient:") - self.aero_surface.cl(filename=filename) + print("Lift coefficient derivative:") self.aero_surface.clalpha_single_fin(filename=filename) def all(self, *, filename=None): diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 02869ece6..4cfc0934b 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -794,10 +794,10 @@ def _center_of_pressure_range(self, plane, max_angle=np.deg2rad(15), samples=31) for angle in angles: if plane == "yz": coeffs = rocket.aerodynamic_coefficients_full(0.0, angle, 0.0) - force, moment = coeffs["cQ"], coeffs["cn"] + force, moment = coeffs["cY"], coeffs["cn"] else: coeffs = rocket.aerodynamic_coefficients_full(angle, 0.0, 0.0) - force, moment = coeffs["cL"], coeffs["cm"] + force, moment = coeffs["cN"], coeffs["cm"] if force == 0: continue position = cdm + csys * diameter * moment / force diff --git a/rocketpy/rocket/aero_surface/_barrowman_surface.py b/rocketpy/rocket/aero_surface/_barrowman_surface.py index 3ae96024b..addc41364 100644 --- a/rocketpy/rocket/aero_surface/_barrowman_surface.py +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -1,25 +1,34 @@ import numpy as np -from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.mathutils.vector_matrix import Matrix, Vector from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface class _BarrowmanSurface(LinearGenericSurface): - """Intermediate base for geometry-defined (Barrowman) aerodynamic surfaces + """Intermediate base for Barrowman-defined aerodynamic surfaces such as nose cones, tails/transitions and fin sets. - These surfaces historically expose a lift-curve slope ``clalpha`` (a - ``Function`` of Mach), a geometric center of pressure ``cpz`` and, for fins, - a pair of roll forcing/damping coefficients. This class translates that - Barrowman description into the linear generic-surface coefficient model so - the forces and moments are computed by the single, shared - :meth:`GenericSurface.compute_forces_and_moments`: - - - normal-force slope -> ``cL_alpha`` (pitch plane) and ``cQ_beta`` (yaw plane); - - center-of-pressure offset -> ``cm_alpha`` / ``cn_beta`` (the moment is - carried by the coefficients, with the force applied at the surface origin); - - fin roll -> ``cl_0`` (cant forcing) and ``cl_p`` (roll damping). + These surfaces expose a lift-curve slope ``clalpha`` (a ``Function`` of + Mach), a geometric center of pressure ``cpz`` and, for fins, a pair of roll + forcing/damping coefficients. + + The in-flight normal force and its moment are computed with the classic + Barrowman method (see :meth:`compute_forces_and_moments`): the normal force + uses the true total angle of attack and acts at the geometric center of + pressure, and its moment about the center of dry mass is the geometric + transport (``cp ^ force``). This reproduces the formulation used in + RocketPy's flight-test validation. The resultant force is therefore reported + at the geometric center of pressure (:attr:`force_application_point`), which + the surface-local frame maps to the body frame through + :meth:`_default_surface_rotation`. + + The class also derives the linear normal-force slopes ``cN_alpha`` (pitch + plane) and ``cY_beta`` (yaw plane), which feed the stability and + center-of-pressure diagnostics; the geometric cp is carried by the force + application point, so the moment slopes ``cm_alpha`` / ``cn_beta`` are zero. + Fin roll uses the coefficient model: ``cl_0`` (cant forcing) and ``cl_p`` + (roll damping). Subclasses must compute ``self.clalpha`` (Function of Mach) and the geometric center of pressure before calling ``super().__init__`` (which passes the @@ -28,7 +37,7 @@ class _BarrowmanSurface(LinearGenericSurface): """ # Geometry-defined Barrowman surfaces are axisymmetric by construction - # (``cQ_beta = -cL_alpha``, etc.), so they contribute identically to the + # (``cY_beta = -cN_alpha``, etc.), so they contribute identically to the # pitch and yaw planes. The individual ``Fin`` overrides this back to False. is_axisymmetric = True @@ -59,47 +68,47 @@ def _beta(mach): else: return np.sqrt(mach**2 - 1) - @property - def force_application_point(self): - """Barrowman surfaces apply the resultant force at the surface origin; - the whole center-of-pressure offset is carried by the ``cm``/``cn`` - moment coefficients (avoiding a double count with the ``cp ^ force`` - transport). The geometric center of pressure remains available through - ``self.cp``/``self.cpz`` for display and through - ``center_of_pressure_z`` as a mach-dependent diagnostic. + def _default_surface_rotation(self): + """Rotation from the surface-local frame to the body frame. A Barrowman + surface is defined in a frame flipped 180 degrees about the transverse + axis relative to the body frame (its z axis runs from the nose toward the + tail), so its geometric center of pressure maps to the body frame through + this rotation. This is RocketPy's classic convention, so the surface's + center of pressure lands at the same body-frame point as before the + generic-surface refactor. """ - return Vector([0, 0, 0]) + return Matrix([[-1, 0, 0], [0, 1, 0], [0, 0, -1]]) def evaluate_coefficients(self): - """Populate the linear generic-surface coefficient derivatives from the - surface geometry. Called by ``GenericSurface.__init__`` and again - whenever the geometry changes. + """Populate the coefficient slopes used by the stability diagnostics + from the surface geometry. Called by ``GenericSurface.__init__`` and + again whenever the geometry changes. + + Sets the normal-force slopes ``cN_alpha`` (pitch) and ``cY_beta`` (yaw) + and the fin roll coefficients when present. The geometric center of + pressure is carried by the force application point (not the moment + coefficients), so ``cm_alpha`` / ``cn_beta`` are zero. The in-flight + force and moment are computed geometrically in + :meth:`compute_forces_and_moments`. """ - clalpha = self.clalpha # Function of Mach - cpz = self.cpz # geometric center of pressure (set from center_of_pressure) - reference_length = self.reference_length - - # Axisymmetric Barrowman lift: equal-magnitude slopes in the pitch and - # yaw planes. The yaw-plane (side-force) slope is opposite in sign due to - # the aerodynamic-to-body frame convention used by the shared compute. - self.cL_alpha = self._mach_coefficient( - lambda mach: clalpha.get_value_opt(mach), "cL_alpha" - ) - self.cQ_beta = self._mach_coefficient( - lambda mach: -clalpha.get_value_opt(mach), "cQ_beta" - ) + clalpha = self.clalpha # normal-force-curve slope, a Function of Mach - # Center-of-pressure offset expressed as moment coefficients (the local - # cp ^ force couple, with the force applied at the origin). - self.cm_alpha = self._mach_coefficient( - lambda mach: -clalpha.get_value_opt(mach) * cpz / reference_length, - "cm_alpha", + # Axisymmetric Barrowman normal force: equal-magnitude slopes in the + # pitch and yaw planes. The yaw-plane (side-force) slope is opposite in + # sign due to the body-frame axis convention. + self.cN_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach), "cN_alpha" ) - self.cn_beta = self._mach_coefficient( - lambda mach: clalpha.get_value_opt(mach) * cpz / reference_length, - "cn_beta", + self.cY_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach), "cY_beta" ) + # The center of pressure is carried by the force application point, so + # the moment slopes add no further offset (the diagnostic recovers the + # geometric cp from the application point alone). + self.cm_alpha = self._mach_coefficient(lambda mach: 0.0, "cm_alpha") + self.cn_beta = self._mach_coefficient(lambda mach: 0.0, "cn_beta") + # Fin roll forcing (cant) and damping, when present. roll_parameters = getattr(self, "roll_parameters", None) if roll_parameters is not None: @@ -111,6 +120,102 @@ def evaluate_coefficients(self): lambda mach: cld_omega.get_value_opt(mach), "cl_p" ) + def compute_forces_and_moments( + self, + stream_velocity, + stream_speed, + stream_mach, + rho, + cp, + omega, + *args, # pylint: disable=unused-argument + ): + """Compute the surface's forces and moments with the classic Barrowman + method. Called at each simulation step. + + The normal force uses the true total angle of attack between the flow + and the body axis, ``attack_angle = arccos(-v_z / |v|)``, giving + ``0.5 * rho * V**2 * A_ref * clalpha(Mach) * attack_angle``. It is + applied perpendicular to the body axis (along the transverse flow) at the + geometric center of pressure, and its moment about the rocket's center of + dry mass is the geometric transport ``cp ^ force``. Fin sets add their + roll moment on top. + + Parameters + ---------- + stream_velocity : Vector + Velocity of the airflow relative to the surface, in the body frame. + stream_speed : float + Magnitude of the airflow speed. + stream_mach : float + Mach number of the airflow. + rho : float + Air density. + cp : Vector + Surface center of pressure relative to the center of dry mass, in + the body frame (the force-application point; see + :attr:`force_application_point`). + omega : tuple of float + Body angular velocity about the x, y, z axes. Only the roll + component (``omega[2]``) is used, by fin sets. + *args + Extra positional arguments accepted for signature compatibility with + the generic surface (``density``, ``dynamic_viscosity``, ``z``, + ``alpha_dot``, ``beta_dot``); unused by the Barrowman model. + + Returns + ------- + tuple of float + The forces (x, y, z) and the moments about the x, y, z axes, in the + body frame. + """ + R1 = R2 = R3 = M1 = M2 = M3 = 0.0 + + stream_vx, stream_vy, stream_vz = stream_velocity + if stream_vx**2 + stream_vy**2 != 0: + stream_vzn = stream_vz / stream_speed + if -stream_vzn < 1: + attack_angle = np.arccos(-stream_vzn) + c_lift = self.clalpha.get_value_opt(stream_mach) * attack_angle + lift = 0.5 * rho * stream_speed**2 * self.reference_area * c_lift + # Normal force, perpendicular to the body axis, directed along + # the transverse component of the flow. + transverse_norm = (stream_vx**2 + stream_vy**2) ** 0.5 + R1 = lift * stream_vx / transverse_norm + R2 = lift * stream_vy / transverse_norm + # The normal force acts at the geometric center of pressure, + # which ``cp`` already locates relative to the center of dry + # mass; transport its moment from there. + force = Vector([R1, R2, R3]) + M1, M2, M3 = cp ^ force + + # Fin roll (cant forcing + rate damping); zero for non-fin surfaces. + M3 += self._roll_moment(stream_speed, stream_mach, rho, omega) + + return R1, R2, R3, M1, M2, M3 + + def _roll_moment(self, stream_speed, mach, rho, omega): + """Roll moment from the linear roll coefficients: cant forcing plus + reduced-rate damping. Returns 0 for surfaces without fins, whose roll + coefficients are identically zero. + """ + reduced_roll_rate = ( + omega[2] * self.reference_length / (2 * stream_speed) + if stream_speed > 0 + else 0.0 + ) + # The Barrowman roll coefficients depend only on Mach and the roll rate. + args = (0.0, 0.0, mach, 0.0, 0.0, 0.0, reduced_roll_rate) + cl = self.clf.get_value_opt(*args) + self.cld.get_value_opt(*args) + return ( + 0.5 + * rho + * stream_speed**2 + * self.reference_area + * self.reference_length + * cl + ) + def _mach_coefficient(self, func_of_mach, name="coefficient"): """Wrap a Mach-only callable into an :class:`AeroCoefficient` that depends only on Mach but is callable over the full coefficient argument diff --git a/rocketpy/rocket/aero_surface/aero_coefficient.py b/rocketpy/rocket/aero_surface/aero_coefficient.py index fa0b35012..264b49a84 100644 --- a/rocketpy/rocket/aero_surface/aero_coefficient.py +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -1,18 +1,3 @@ -"""Minimal-dimension aerodynamic coefficient storage. - -A :class:`AeroCoefficient` stores a single aerodynamic coefficient at its -*intrinsic* dimensionality - a constant, or a :class:`Function` over only the -variables the coefficient actually depends on (its ``depends_on``) - and maps -the full coefficient argument tuple (in ``independent_vars`` order) down to that -subset on every call. - -This avoids forcing a Mach-only (or constant) coefficient into a full seven -dimensional :class:`Function`: interpolation happens at the right dimension (so -a Mach-only table is not smeared across a 7-D domain) and evaluation passes only -the arguments that matter. It generalizes the per-call ``dict(zip(...))`` subset -selection that the CSV loader used to do inline. -""" - import copy import csv import inspect @@ -48,13 +33,8 @@ def build_independent_vars(unsteady_aero=False, control_variables=()): class AeroCoefficient: - """A single aerodynamic coefficient stored at minimal dimensionality. - - Building goes through :meth:`__init__`: pass a raw coefficient input - (number, callable, :class:`Function`, list/tuple of points, CSV path, or - another :class:`AeroCoefficient`) and ``depends_on`` is inferred; pass - ``depends_on`` explicitly only on the fast path where it is already known. - """ + """A single aerodynamic coefficient (such as lift or drag), stored using + only the variables it actually depends on.""" def __init__( self, @@ -64,119 +44,125 @@ def __init__( control_variables=(), name="coefficient", extrapolation=None, + interpolation=None, single_var=None, ): - """Build a coefficient stored at minimal dimensionality. - - A number is kept as a plain constant. Anything else is wrapped in a - :class:`Function` over only the variables it depends on (``depends_on``), - so a Mach-only curve stays 1-D instead of being stretched across all - seven axes. On each call the full argument tuple is mapped down to just - those arguments (using the precomputed ``_indices``). The full, ordered - list of variables comes from ``unsteady_aero`` and ``control_variables`` - via :func:`build_independent_vars`. - - Usually you do not pass ``depends_on``: leave it as ``None`` and it is - worked out from ``source`` (a number, a callable, a :class:`Function`, a - list of points, a CSV path, or another :class:`AeroCoefficient`), the - same inputs :class:`GenericSurface` accepts (see :meth:`_resolve_input`). - Pass ``depends_on`` yourself only on the fast path, where the source and - its argument order are already known (the Barrowman surfaces and - serialization). + """Build a coefficient from a value, a data table, or a function. + + A plain number is stored as a constant. Anything else is stored as a + :class:`Function` of only the variables it depends on, so a coefficient + that varies with Mach alone stays a simple 1-D curve instead of being + spread across all seven variables. When the coefficient is evaluated, + the variables it does not use are simply ignored. + + Most of the time you only pass ``source`` and leave ``depends_on`` as + ``None``, so the variables are worked out automatically. This is the + same input a :class:`GenericSurface` accepts. Pass ``depends_on`` + yourself only when the source and the order of its inputs are already + known (used internally by the Barrowman surfaces and when loading a + saved rocket). Parameters ---------- - source : number, str, list, tuple, callable, Function, or AeroCoefficient - The coefficient value, or an input it can be worked out from when - ``depends_on`` is ``None``. The accepted forms are: - - - **number**: kept as a constant. Calls return it directly, and - ``is_zero`` is set when it is exactly ``0.0`` (the linear model - uses that to skip the term). It depends on nothing. - - **callable** (function or ``lambda``): wrapped in a - :class:`Function`. When ``depends_on`` is worked out, the - parameter *names* decide it: name them after the variables they - use (e.g. ``lambda alpha, mach: ...``), give one argument per - variable, or use one argument together with ``single_var``. - - **Function**: used as given. If ``extrapolation`` is set, it is - applied to a copy, never to the object you passed in (it may be - shared elsewhere). - - **list/tuple of points**: turned into a :class:`Function` with - linear interpolation, so a list and the same data in a CSV give - the same result. - - **str**: a path to a data file. A ``.csv`` file is read by the CSV - loader (column headers name the variables; a headerless - two-column file is a 1-D table over ``single_var``); other files - are read by :class:`Function`. - - **AeroCoefficient**: an existing coefficient, re-keyed to this - surface's variables. This is what lets a surface round-trip - through ``to_dict``/``from_dict`` and lets one coefficient be - reused on several surfaces. + source : int, float, str, list, tuple, callable, Function, or AeroCoefficient + The coefficient value, given in one of these forms: + + - **number**: a constant coefficient that never changes. + - **function or lambda**: a coefficient computed from its inputs. + Name the arguments after the variables they use (e.g. + ``lambda alpha, mach: ...``), or give one argument per variable, + or a single argument together with ``single_var``. + - **Function**: a :class:`Function` you already built, used as is. + If ``extrapolation`` is given it is applied to a copy, so the + Function you passed in is left unchanged. + - **list or tuple of data points**: a table of values, read the + same way as the same data in a CSV file. The variables it depends + on are worked out from the table, using ``single_var`` for a + one-input table. + - **str**: the path to a data file. A ``.csv`` file has one column + per variable (named in the header) and the coefficient value in + the last column; a headerless two-column file is a table of + ``single_var`` versus the value. + - **AeroCoefficient**: an existing coefficient, reused as is. This + lets one coefficient be shared by several surfaces and lets a + rocket be saved and loaded. depends_on : sequence of str, optional - The variables this coefficient actually uses, a (possibly empty) - subset of the surface's full variable list (set by ``unsteady_aero`` - and ``control_variables``). Keep them in the same order as the - source's own arguments (a callable's parameters, a CSV's columns): - that order is used to pick the right values out of the full argument - tuple on each call. For example, ``()`` for a constant, ``("mach",)`` - for a Mach-only curve, or the whole list for something that uses - every variable. A name that is not one of the surface's variables - raises a ``ValueError``. Leave it as ``None`` (the default) to have - it worked out from ``source``; pass it only on the fast path, where - the source and its argument order are already known. + The variables this coefficient actually uses, chosen from the + surface's variables: the seven base ones ``"alpha"``, ``"beta"``, + ``"mach"``, ``"reynolds"``, ``"pitch_rate"``, ``"yaw_rate"``, + ``"roll_rate"``, plus ``"alpha_dot"`` and ``"beta_dot"`` when + ``unsteady_aero`` is ``True``, plus any names in + ``control_variables``. List them in the same order as the source's + own inputs (a function's arguments, a CSV's columns). For example, + ``()`` for a constant, ``("mach",)`` for a Mach-only curve, or the + whole list for something that uses every variable. A name that is + not one of the surface's variables raises a ``ValueError``. Leave it + as ``None`` (the default) to have it worked out from ``source``. unsteady_aero : bool, optional - Add the unsteady axes to this coefficient's variables. When ``True``, - ``alpha_dot`` and ``beta_dot`` (the rates of change of the angle of - attack and sideslip) are added after the seven base axes, so calls - take two more arguments. The flight integrator fills these in, using - ``0`` when it does not compute them, so ordinary tables keep working. - Match the owning surface's setting. Default ``False``. + Whether the coefficient can also depend on how fast the flow angles + are changing. When ``True``, two more variables, ``alpha_dot`` and + ``beta_dot`` (the rates of change of the angle of attack and + sideslip), are added after the seven base variables. The simulation + fills these in, using ``0`` when it does not compute them, so + ordinary coefficients keep working. This must match the surface the + coefficient belongs to. Default ``False``. control_variables : sequence of str, optional - Names of extra axes supplied from outside, such as control-surface - deflections from a controller. They are added after the base and - unsteady axes, and each one becomes an extra call argument, in the - order given. Used by :class:`ControllableGenericSurface` and air - brakes; empty for ordinary surfaces. Default ``()``. + Names of extra variables, such as control-surface deflections set by + a controller. They are added after the base (and unsteady) variables, + in the order given. Empty for ordinary surfaces. Default ``()``. name : str, optional A readable name for the coefficient (e.g. ``"cL_alpha"`` or - ``"Drag Coefficient with Power Off"``). It labels the underlying - :class:`Function` and appears in error messages, so a clear name - makes problems easier to spot. Default ``"coefficient"``. + ``"Drag Coefficient with Power Off"``). It appears in error messages, + so a clear name makes problems easier to spot. Default + ``"coefficient"``. extrapolation : str, optional - How the stored :class:`Function` behaves outside its data range, one - of the options of :meth:`Function.set_extrapolation`: ``"constant"`` - holds the edge value (used for drag, which should not run past its - data), ``"natural"`` keeps following the curve, ``"zero"`` returns - ``0``. ``None`` (the default) leaves a :class:`Function` you passed - in unchanged, and uses ``"natural"`` for one built from a callable. - An override is always applied to a copy, so your object is never - changed. + What the coefficient does outside the range of its data table: + ``"constant"`` holds the value at the nearest edge (the safe default + for aerodynamic coefficients, which should not shoot off to + unrealistic values), ``"natural"`` keeps following the curve, and + ``"zero"`` returns ``0``. ``None`` (the default) leaves a + :class:`Function` you passed in unchanged and uses ``"constant"`` for + a table built here. Has no effect on a constant or a function, which + are evaluated directly. + interpolation : str, optional + How the coefficient reads values *between* the points of its data + table, for example ``"linear"``, ``"akima"`` or ``"spline"`` for a + one-input table. Only affects data tables (CSV files, lists of + points, a :class:`Function`); it has no effect on a constant or a + function. ``None`` (the default) leaves a :class:`Function` you + passed in unchanged and uses ``"linear"`` for a table built here. single_var : str, optional - Which variable a 1-D input maps to. Used only while working out - ``depends_on`` for a single-dimension source: a headerless - two-column CSV, a 1-D :class:`Function`, or a one-argument callable. - ``None`` (the default) guesses it from the input's label, falling - back to the first variable; drag passes ``"mach"`` so a plain - Cd-vs-Mach curve maps to Mach. Ignored when ``depends_on`` is given. - Default ``None``. + Which variable a one-input table or function maps to. Used only when + working out the variables of a single-input source: a headerless + two-column CSV, a one-input :class:`Function`, or a one-argument + function. ``None`` (the default) guesses it from the input's label + and otherwise falls back to the first variable. Ignored when + ``depends_on`` is given. Default ``None``. """ self.name = name self.extrapolation = extrapolation + self.interpolation = interpolation self.unsteady_aero = unsteady_aero self.control_variables = tuple(control_variables) + # ``unsteady_aero`` and ``control_variables`` define the full ordered + # variable list: every coefficient's argument order and each variable's + # position. This is a surface-wide property, distinct from ``depends_on`` + # (the subset a single coefficient reads), and it is passed in rather + # than derived from ``depends_on``: inferring ``depends_on`` already + # needs this list, and the unsteady axes shift the position of the + # control variables even for coefficients that never use the rates. self.independent_vars = tuple( build_independent_vars(unsteady_aero, control_variables) ) # Infer the stored source and its dependencies from the raw input when - # ``depends_on`` is not given. ``_resolve_input`` may also adopt the - # input's extrapolation (re-keying an AeroCoefficient), so refresh the - # local ``extrapolation`` used by the source-storage block below. + # ``depends_on`` is not given. if depends_on is None: source, depends_on = self._resolve_input(source, single_var) extrapolation = self.extrapolation + interpolation = self.interpolation # ``depends_on`` is kept in the given order because it matches the # positional argument order of the stored source (callable parameters, - # CSV columns, …). ``_indices`` therefore maps the full argument tuple + # CSV columns, …). ``_indices`` then maps the full argument tuple # to the source's own argument order. self.depends_on = tuple(depends_on) unknown = [var for var in self.depends_on if var not in self.independent_vars] @@ -192,21 +178,26 @@ def __init__( self.is_zero = False self._constant = None if isinstance(source, Function): - # Only override extrapolation when explicitly asked, and on a copy: - # the source may be a user-owned Function reused elsewhere, so - # mutating it in place (e.g. drag forcing "constant") would change - # its behavior everywhere the caller reuses it. - if extrapolation is not None: + # Only override interpolation/extrapolation when explicitly asked, + # and always on a copy (the Function may be shared elsewhere). + if interpolation is not None or extrapolation is not None: source = copy.deepcopy(source) - source.set_extrapolation(extrapolation) + # Interpolation names like "akima"/"spline" are 1-D concepts; a + # multi-dimensional Function (e.g. a regular grid) keeps its own + # interpolation, whose method is fixed when the grid is built, so + # a 1-D name here would wrongly fall back to "shepard". + if interpolation is not None and source.__dom_dim__ == 1: + source.set_interpolation(interpolation) + if extrapolation is not None: + source.set_extrapolation(extrapolation) self.function = source elif callable(source): self.function = Function( source, list(self.depends_on) or ["x"], [name], - interpolation="linear", - extrapolation=extrapolation or "natural", + interpolation=interpolation or "linear", + extrapolation=extrapolation or "constant", ) else: # Scalar constant. @@ -219,13 +210,23 @@ def __init__( def _resolve_input(self, source, single_var): """Infer ``(stored source, depends_on)`` from a raw coefficient input. - Mirrors the coefficient inputs accepted by :class:`GenericSurface`: a - number, a callable, a :class:`Function`, a list/tuple of data points, a - path to a CSV (or other text) file, or another :class:`AeroCoefficient` - (re-keyed). - Called by :meth:`__init__` when ``depends_on`` is omitted; the returned - ``source`` is a number, a callable or a :class:`Function`, which the - constructor's source-storage block then stores. + Parameters + ---------- + source : int, float, str, list, tuple, callable, Function or AeroCoefficient + Raw coefficient input: a scalar, a CSV file path (or any other path + read by :class:`Function`), a list/tuple of data points, a callable, + a pre-built :class:`Function`, or an existing ``AeroCoefficient``. + single_var : str or None + Name of the independent variable a one-dimensional input depends on. + When ``None``, it is inferred from the source (see + :meth:`_infer_single_var` / :meth:`_infer_callable_depends_on`). + + Returns + ------- + tuple + ``(stored_source, depends_on)`` where ``stored_source`` is the scalar + or :class:`Function` kept internally and ``depends_on`` is the tuple + of independent-variable names it depends on. """ name = self.name independent_vars = self.independent_vars @@ -233,11 +234,8 @@ def _resolve_input(self, source, single_var): if isinstance(source, AeroCoefficient): # An already-built coefficient passed straight through, re-keyed to - # this surface's variable order. This is how a *surface* round-trips: - # GenericSurface/ControllableGenericSurface store their processed - # AeroCoefficients in ``to_dict`` and feed them back on ``from_dict`` - # (and a user may reuse one coefficient across surfaces). Adopt its - # extrapolation when none was requested. + # this surface's variable order. Adopt its extrapolation when none + # was requested. if self.extrapolation is None: self.extrapolation = source.extrapolation value = ( @@ -251,23 +249,25 @@ def _resolve_input(self, source, single_var): source, name, independent_vars, - extrapolation=self.extrapolation or "natural", + extrapolation=self.extrapolation or "constant", + interpolation=self.interpolation or "linear", single_var=single_var, ) - # Any other path (e.g. a whitespace-delimited ``.txt`` curve) is read - # by Function, which auto-detects the delimiter. Linear interpolation - # matches the CSV loader, so the same data gives identical results - # whatever file form it is given. Falls through to the Function - # branch below (a 1-D table keyed to ``single_var``). - source = Function(source, interpolation="linear") - - # A list/tuple of data points is parsed by Function and handled below. - # Linear interpolation matches the CSV loader, so the same tabular data - # gives identical results whether supplied as a list or a CSV file - # (Function would otherwise default to spline). + # Any other path is read by Function + source = Function( + source, + interpolation=self.interpolation or "linear", + extrapolation=self.extrapolation or "constant", + ) + + # A list/tuple of data points is parsed by Function and handled below if isinstance(source, (list, tuple)): try: - source = Function(list(source), interpolation="linear") + source = Function( + list(source), + interpolation=self.interpolation or "linear", + extrapolation=self.extrapolation or "constant", + ) except (TypeError, ValueError) as exc: raise TypeError( f"Invalid list/tuple input for {name}: could not be parsed " @@ -306,7 +306,12 @@ def _resolve_input(self, source, single_var): @staticmethod def _load_csv( - file_path, name, independent_vars, extrapolation="natural", single_var=None + file_path, + name, + independent_vars, + extrapolation="constant", + interpolation="linear", + single_var=None, ): # pylint: disable=too-many-statements """Load a coefficient CSV at minimal dimension. @@ -327,7 +332,12 @@ def _load_csv( the CSV header columns. extrapolation : str, optional Extrapolation method for the loaded ``Function``. Defaults to - ``"natural"``; drag coefficients pass ``"constant"``. + ``"constant"`` (holds the edge value past the tabulated range). + interpolation : str, optional + Interpolation method for the loaded ``Function``. Defaults to + ``"linear"``. For 1-D and non-grid tables it is used directly; a + strict Cartesian grid uses ``"regular_grid"`` with the method mapped + from this value (see :meth:`Function.from_regular_grid_csv`). single_var : str, optional Independent variable a headerless two-column table depends on. Defaults to the first independent variable. @@ -367,7 +377,7 @@ def _is_numeric(value): if len(header) == 2 and all(_is_numeric(cell) for cell in header): csv_func = Function( file_path, - interpolation="linear", + interpolation=interpolation, extrapolation=extrapolation, ) return csv_func, [single_var or independent_vars[0]] @@ -399,17 +409,15 @@ def _is_numeric(value): ordered_present_columns, name, extrapolation=extrapolation, + interpolation=interpolation, ) if csv_func is None: csv_func = Function( file_path, - interpolation="linear", + interpolation=interpolation, extrapolation=extrapolation, ) - # The CSV columns may appear in any order; AeroCoefficient maps the full - # argument tuple to ``ordered_present_columns`` order, so the stored - # Function is queried directly at its own (minimal) dimensionality. return csv_func, ordered_present_columns @staticmethod @@ -433,20 +441,25 @@ def _infer_single_var(function, independent_vars): @staticmethod def _infer_callable_depends_on(func, independent_vars, name, single_var=None): - """Infer ``depends_on`` for a plain callable. - - Conventions are accepted in order: - - 0. *Single variable* - when ``single_var`` is given and the callable - takes a single argument, it depends on that one variable regardless - of the parameter name (e.g. a Mach-only drag ``lambda mach: ...``). - 1. *Named subset* - every parameter name is an independent variable, so - the parameters themselves name the dependency subset (e.g. - ``lambda alpha, mach: ...``). - 2. *Positional full-arity* - the parameter count equals the number of - independent variables, so the callable depends on all of them - regardless of how its parameters are named (e.g. - ``lambda a, b, m, r, p, q, rr: ...``). + """Work out which variables a function coefficient uses, from its + arguments. + + Three ways to write the function are accepted, tried in this order: + + 1. One argument plus ``single_var``: the function takes a single + argument and ``single_var`` says which variable it is, whatever the + argument is named (e.g. a Mach-only drag curve ``lambda mach: ...`` + with ``single_var="mach"``). + 2. Arguments named after variables: every argument name matches one of + the surface's variables, so the names themselves list what the + function uses (e.g. ``lambda alpha, mach: ...`` uses ``alpha`` and + ``mach``). + 3. One argument per variable: the function has exactly as many arguments + as there are variables, so it is taken to use all of them, whatever + the arguments are named (e.g. ``lambda a, b, m, r, p, q, rr: ...`` + for the seven base variables). + + Anything else raises ``ValueError``. """ n_vars = len(independent_vars) try: @@ -469,19 +482,21 @@ def _infer_callable_depends_on(func, independent_vars, name, single_var=None): @property def is_zero_coefficient(self): - """Back-compat alias used by the linear model's hot-loop term skipping.""" + """Kept-for-compatibility alias of ``is_zero``: whether the coefficient + is the constant 0 (the linear model uses it to skip zero terms).""" return self.is_zero @property def __dom_dim__(self): - """Number of full independent variables (the call arity).""" + """Number of variables the coefficient is called with.""" return len(self.independent_vars) def get_value_opt(self, *args): - """Fast, unvalidated evaluation (mirrors :meth:`Function.get_value_opt`). + """Fast evaluation without input checking (mirrors + :meth:`Function.get_value_opt`). - Maps the full ``independent_vars`` argument tuple down to the source's - own ``depends_on`` arguments before evaluating; a constant short-circuits. + Receives every variable, passes on only the ones this coefficient uses, + and evaluates the source. A constant is returned right away. """ if self._constant is not None: return self._constant @@ -506,6 +521,7 @@ def __mul__(self, other): self.control_variables, self.name, extrapolation=self.extrapolation, + interpolation=self.interpolation, ) __rmul__ = __mul__ @@ -519,6 +535,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "control_variables": list(self.control_variables), "name": self.name, "extrapolation": self.extrapolation, + "interpolation": self.interpolation, } @classmethod @@ -531,6 +548,7 @@ def from_dict(cls, data): data.get("control_variables", ()), data["name"], extrapolation=data.get("extrapolation"), + interpolation=data.get("interpolation"), ) def __repr__(self): @@ -538,3 +556,61 @@ def __repr__(self): if self._constant is not None: return f"AeroCoefficient({self.name}={self._constant})" return f"AeroCoefficient({self.name}, depends_on={self.depends_on})" + + def slice(self, *free_variables, at=None): + """Return a :class:`Function` of only the chosen variables, holding the + others fixed. + + This gives a lower-dimensional view of the coefficient, handy for + inspection or plotting. For example, ``cL.slice("alpha", "mach")`` is the + lift coefficient as a function of angle of attack and Mach, with sideslip, + Reynolds number and the rotation rates held at zero; ``cD.slice("mach")`` + is a Mach-only drag curve. + + Parameters + ---------- + *free_variables : str + Names of the variables to keep as inputs, in the order you want them + (for example ``"mach"`` or ``"alpha", "mach"``). Each must be one of + this coefficient's independent variables. + at : dict, optional + Values to hold the remaining variables at, keyed by variable name. + Any not listed are held at 0. + + Returns + ------- + Function + A Function of ``free_variables`` that evaluates this coefficient with + the remaining variables held fixed. + """ + fixed = dict(at or {}) + unknown = [ + var + for var in list(free_variables) + list(fixed) + if var not in self.independent_vars + ] + if unknown: + raise ValueError( + f"{self.name} has no independent variable(s) {unknown}; valid " + f"variables are {list(self.independent_vars)}." + ) + + free_positions = [self.independent_vars.index(var) for var in free_variables] + baseline = [fixed.get(var, 0.0) for var in self.independent_vars] + + if not free_variables: + return Function(self.get_value_opt(*baseline)) + + def sliced(*values): + args = list(baseline) + for position, value in zip(free_positions, values): + args[position] = value + return self.get_value_opt(*args) + + # Give the wrapper an explicit signature so Function reads the right + # number of inputs (its domain dimension comes from the parameter count). + sliced.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in free_variables + ) + return Function(sliced, list(free_variables), [self.name]) diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index 221440dc6..0e7a5965f 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -9,7 +9,6 @@ from .controllable_generic_surface import ControllableGenericSurface -# TODO: review airbrakes implementation to make it more in line with events class AirBrakes(ControllableGenericSurface): """AirBrakes class. Inherits from :class:`ControllableGenericSurface`, using ``deployment_level`` as its single control variable and a multivariable drag @@ -93,7 +92,7 @@ def __init__( Default is False. deployment_level : float, optional Initial deployment level, ranging from 0 to 1. Deployment level is - the fraction of the total airbrake area that is Deployment. Default + the fraction of the total airbrake area that is deployed. Default is 0. name : str, optional Name of the air brakes. Default is "AirBrakes". diff --git a/rocketpy/rocket/aero_surface/controllable_generic_surface.py b/rocketpy/rocket/aero_surface/controllable_generic_surface.py index 793c49936..52ad1ae1f 100644 --- a/rocketpy/rocket/aero_surface/controllable_generic_surface.py +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -2,24 +2,23 @@ class ControllableGenericSurface(GenericSurface): - """A generic aerodynamic surface whose coefficients additionally depend on - one or more **control-deflection** variables (canards, grid fins, elevons, - air-brake deployment, …) sourced at runtime from a controller. + """A generic aerodynamic surface whose coefficients also depend on one or + more control inputs (canards, grid fins, elevons, air-brake deployment, and + so on) set by a controller while the rocket flies. - On top of the seven standard independent variables of - :class:`GenericSurface` (``alpha``, ``beta``, ``mach``, ``reynolds``, - ``pitch_rate``, ``yaw_rate``, ``roll_rate``), the coefficient functions take - one extra argument per entry of ``controls`` (appended in order). The - current control values are held in :attr:`control_state` and mutated each - simulation step by a controller (see ``Rocket.add_controllable_surface``); - :meth:`_coefficient_arguments` appends them to every coefficient evaluation. + On top of the seven standard variables of :class:`GenericSurface` + (``alpha``, ``beta``, ``mach``, ``reynolds``, ``pitch_rate``, ``yaw_rate``, + ``roll_rate``), each coefficient takes one extra input per control, in the + order listed in ``controls``. A controller updates the current control + values every simulation step (see ``Rocket.add_controllable_surface``), and + they are passed to the coefficients automatically. Attributes ---------- ControllableGenericSurface.control_variables : list of str - Names of the control-deflection axes, in coefficient-argument order. + Names of the controls, in the order the coefficients expect them. ControllableGenericSurface.control_state : dict - Current value of each control variable (defaults to 0). + Current value of each control (starts at 0). """ # TODO: deflection-dependent static-margin diagnostics. @@ -32,7 +31,7 @@ class ControllableGenericSurface(GenericSurface): # # The gap is diagnostic-only. The derived ``center_of_pressure_z`` / # ``aerodynamic_center`` come from ``cm_alpha = d(cm)/d(alpha)`` evaluated ONCE - # (in ``_set_derived_cp_accessors``) with the control variables frozen at their + # (in ``_set_stability_accessors``) with the control variables frozen at their # value at construction (0). So if ``cm`` couples alpha and a control axis # (e.g. an ``alpha * deflection`` term), the reported ``static_margin`` is # pinned to the zero-deflection configuration and does not track ``set_control``. @@ -59,6 +58,8 @@ def __init__( center_of_pressure=(0, 0, 0), name="Controllable Generic Surface", controls=("deflection",), + extrapolation=None, + interpolation=None, ): """Create a controllable generic aerodynamic surface. @@ -69,19 +70,34 @@ def __init__( reference_length : int, float Reference length of the surface, in meters. coefficients : dict - Aerodynamic coefficients (``cL``, ``cQ``, ``cD``, ``cm``, ``cn``, - ``cl``), each a callable/CSV/Function of the seven base variables - **plus** the control variables listed in ``controls`` (appended in - order). Omitted coefficients default to 0. + The six force and moment coefficients (``cL``, ``cQ``, ``cD``, + ``cm``, ``cn``, ``cl``), by name. Each one can be a constant, a + function, or a path to a data file, and depends on the seven base + variables **plus** the controls listed in ``controls`` (in that + order). Any you leave out are set to 0. center_of_pressure : tuple, list, optional Application point of the aerodynamic forces and moments in the local surface frame. Default ``(0, 0, 0)``. name : str, optional Name of the surface. Default ``"Controllable Generic Surface"``. controls : iterable of str, optional - Names of the control-deflection axes. Default ``("deflection",)``. - Each name becomes an extra coefficient argument and a key in - :attr:`control_state`. + Names of the controls, such as a canard deflection angle. Default + ``("deflection",)``. Each name becomes an extra input to every + coefficient and a key in :attr:`control_state`. + extrapolation : str or dict, optional + What tabulated coefficients do outside their data range: + ``"constant"`` holds the nearest edge value, ``"natural"`` keeps + following the curve, ``"zero"`` returns 0. Give one string for all + coefficients or a dict keyed by coefficient name. ``None`` (the + default) uses ``"constant"`` for tables built here and leaves a + pre-built :class:`Function` unchanged. + interpolation : str or dict, optional + How tabulated coefficients read values between points (for example + ``"linear"``, ``"akima"`` or ``"spline"`` for a 1-D table; see + :class:`rocketpy.GenericSurface` for the full list by table type). + Give one string for all coefficients or a dict keyed by coefficient + name. ``None`` (the default) uses ``"linear"`` for tables built here + and leaves a pre-built :class:`Function` unchanged. """ # These must be set before ``super().__init__`` so coefficient # processing (arity, CSV validation) and the derived-cp accessors see @@ -96,6 +112,8 @@ def __init__( coefficients=coefficients, center_of_pressure=center_of_pressure, name=name, + extrapolation=extrapolation, + interpolation=interpolation, ) # ``self.prints``/``self.plots`` are the generic ones wired by the base. @@ -162,9 +180,9 @@ def to_dict( # pylint: disable=unused-argument "reference_area": self.reference_area, "reference_length": self.reference_length, "coefficients": { - "cL": self.cL, - "cQ": self.cQ, - "cD": self.cD, + "cN": self.cN, + "cY": self.cY, + "cA": self.cA, "cm": self.cm, "cn": self.cn, "cl": self.cl, diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py index 332326aea..9b2b7a536 100644 --- a/rocketpy/rocket/aero_surface/fins/_base_fin.py +++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py @@ -73,7 +73,7 @@ def _update_geometry_chain(self): # Geometry changed after construction: refresh the coefficients. self.evaluate_coefficients() self.compute_all_coefficients() - self._evaluate_derived_coefficients() + self._evaluate_stability_derivatives() else: self._finalize_barrowman() @@ -320,7 +320,7 @@ def evaluate_single_fin_lift_coefficient(self): 2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c)) ) - # Lift coefficient derivative for a single fin + # Normal-force coefficient derivative for a single fin def lift_source(mach): return ( clalpha2D(mach) @@ -336,7 +336,7 @@ def lift_source(mach): self.clalpha_single_fin = Function( lift_source, "Mach", - "Lift coefficient derivative for a single fin", + "Normal-force coefficient derivative for a single fin", ) @abstractmethod diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py index 249a8cf70..1db9dc75d 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py @@ -77,11 +77,12 @@ class EllipticalFin(Fin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. EllipticalFin.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. EllipticalFin.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. """ def __init__( diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py index 4576bd1f3..74aea986a 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py @@ -80,11 +80,12 @@ class EllipticalFins(Fins): Fin set local center of pressure z coordinate. Has units of length and is given in meters. EllipticalFins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. EllipticalFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. """ def __init__( diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py index b2875e658..c9ca3e490 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -81,19 +81,19 @@ class Fin(_BaseFin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. Fin.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. Fin.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. Fin.roll_parameters : list List containing the roll moment lift coefficient, the roll moment damping coefficient and the cant angle in radians. """ - # A single fin contributes unequally to the pitch and yaw planes - # (``cL_alpha`` ~ sin^2(phi), ``cQ_beta`` ~ cos^2(phi)), so it is not - # axisymmetric on its own. A complete, evenly spaced set may still be + # A single fin contributes unequally to the pitch and yaw planes, so it is + # not axisymmetric on its own. A complete, evenly spaced set may still be # axisymmetric collectively, which the rocket's numeric check resolves. is_axisymmetric = False @@ -155,17 +155,8 @@ def __init__( self._angular_position_rad = math.radians(angular_position) def _update_geometry_chain(self): - """Run the base geometry/coefficient chain, then (re)build the body<->fin - rotation matrices. - - The rotation matrices must be set **after** the chain: the chain's first - call initializes the generic-surface machinery, which resets - ``_rotation_surface_to_body`` to the identity. Doing it here (rather than - in each concrete fin's ``__init__``) ensures every individual-fin - subclass -- trapezoidal, elliptical and free-form -- gets correct, - angular-position-aware rotation matrices on construction and whenever the - geometry changes. - """ + """Run the base geometry/coefficient chain, then (re)build the body to + fin rotation matrices.""" super()._update_geometry_chain() self.evaluate_rotation_matrix() @@ -224,14 +215,7 @@ def evaluate_lift_coefficient(self): self.clalpha = self.clalpha_single_fin * self.lift_interference_factor - # Cl = clalpha * alpha - self.cl = Function( - lambda alpha, mach: alpha * self.clalpha(mach), - ["Alpha (rad)", "Mach"], - "Lift coefficient", - ) - - return self.cl + return self.clalpha def evaluate_roll_parameters(self): """Calculates and returns the fin set's roll coefficients. @@ -302,12 +286,7 @@ def evaluate_rotation_matrix(self): sin_delta = math.sin(delta) cos_delta = math.cos(delta) - # The body -> fin change of basis is composed right-to-left as - # ``R_delta @ R_phi @ R_pi`` (R_pi first, R_delta last). Each factor - # therefore acts on the coordinates produced by the factors to its right, - # i.e. in the *current* (partially rotated) frame, not the body frame. - - # Roll by the angular position, about the rocket longitudinal axis. + # Rotation about body Z by angular position R_phi = Matrix( [ [cos_phi, -sin_phi, 0], @@ -316,12 +295,7 @@ def evaluate_rotation_matrix(self): ] ) - # Cant rotation about the fin **span (y) axis**. Because R_delta is the - # leftmost factor, it acts on coordinates already in the rolled - # uncanted-fin frame, so it rotates about that frame's y axis (the fin's - # own root-to-tip direction) -- NOT body Y, with which it coincides only - # at angular_position = 0. This is what makes each fin cant about its own - # span; using body Y (``R_uncanted @ R_delta``) would be wrong. + # Cant rotation about body Y R_delta = Matrix( [ [cos_delta, 0, -sin_delta], @@ -330,9 +304,7 @@ def evaluate_rotation_matrix(self): ] ) - # 180 flip about Y so the uncanted fin z axis points leading -> trailing - # edge (toward the tail, i.e. -body z), with x completing a right-handed - # frame. Proper rotation (det +1), not a reflection. + # 180 flip about Y to align fin leading/trailing edge R_pi = Matrix( [ [-1, 0, 0], @@ -341,7 +313,7 @@ def evaluate_rotation_matrix(self): ] ) - # Uncanted body -> fin, then apply the cant in the fin span frame. + # Uncanted body to fin, then apply cant R_uncanted = R_phi @ R_pi R_body_to_fin = R_delta @ R_uncanted @@ -353,36 +325,30 @@ def evaluate_rotation_matrix(self): @property def force_application_point(self): - """A single (off-axis) fin keeps its bespoke force computation and - transports the moment geometrically through its center of pressure, - so the force application point is the fin's actual cp rather than the - surface origin used by axisymmetric Barrowman surfaces. + """Point where the fin's aerodynamic force is applied, in body frame. + + Returns + ------- + Vector + The fin's center of pressure ``[cpx, cpy, cpz]``. """ return Vector([self.cpx, self.cpy, self.cpz]) def evaluate_coefficients(self): - """A single fin transports its moment geometrically (via ``cp ^ force`` - in its own ``compute_forces_and_moments``), so only the normal-force - slopes are exposed for the stability-margin diagnostic; the moment - coefficients stay zero to avoid double-counting the cp offset. - - A fin's lift only resists incidence in its own plane, so its slope is - projected onto the pitch and yaw planes by its angular position - ``phi``: ``sin(phi)**2`` to the pitch plane (``cL_alpha``) and - ``cos(phi)**2`` to the yaw plane (``cQ_beta``). A fin at ``phi = 0`` - (lying in the yaw plane) thus feeds the yaw plane only, which is what - makes a non-axisymmetric individual-fin layout report different pitch- - and yaw-plane centers of pressure. An evenly spaced set of ``n`` fins - sums to ``n / 2`` in each plane, reproducing the axisymmetric ``Fins`` - set (see :meth:`Fins.fin_num_correction`). + """Evaluate the fin's normal-force slope coefficients. + + Sets ``cN_alpha`` (pitch plane) and ``cY_beta`` (yaw plane) from the + fin's normal-force slope projected onto each plane by its angular + position. Moment coefficients are left at zero since the moment is + transported geometrically in :meth:`compute_forces_and_moments`. """ clalpha = self.clalpha sin_sq = math.sin(self.angular_position_rad) ** 2 cos_sq = math.cos(self.angular_position_rad) ** 2 - self.cL_alpha = self._mach_coefficient( + self.cN_alpha = self._mach_coefficient( lambda mach: clalpha.get_value_opt(mach) * sin_sq ) - self.cQ_beta = self._mach_coefficient( + self.cY_beta = self._mach_coefficient( lambda mach: -clalpha.get_value_opt(mach) * cos_sq ) @@ -412,6 +378,10 @@ def compute_forces_and_moments( Center of pressure coordinates in the body frame. omega: tuple[float, float, float] Tuple containing angular velocities around the x, y, z axes. + *args + Extra positional arguments accepted for signature compatibility with + the generic surface (e.g. ``density``, ``dynamic_viscosity``, ``z``, + ``alpha_dot``, ``beta_dot``). Unused by the fin's Barrowman model. Returns ------- @@ -431,7 +401,8 @@ def compute_forces_and_moments( * rho * stream_speed**2 * self.reference_area - * self.cl.get_value_opt(attack_angle, stream_mach) + * self.clalpha.get_value_opt(stream_mach) + * attack_angle ) # Force in body frame R1, R2, R3 = self._rotation_fin_to_body @ Vector([X, 0, 0]) @@ -506,7 +477,7 @@ def to_dict(self, include_outputs=False): data.update( { "cp": self.cp, - "cl": self.cl, + "clalpha": self.clalpha, "roll_parameters": self.roll_parameters, "rocket_diameter": self.rocket_diameter, "diameter": self.rocket_diameter, diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index 9913b3f5b..781857be3 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -80,11 +80,12 @@ class Fins(_BaseFin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. Fins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. Fins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. Fins.roll_parameters : list List containing the roll moment lift coefficient, the roll moment damping coefficient and the cant angle in radians. @@ -165,7 +166,7 @@ def evaluate_lift_coefficient(self): """ self.evaluate_single_fin_lift_coefficient() - # Lift coefficient derivative for n fins corrected with Fin-Body interference + # Normal-force coefficient derivative for n fins corrected with Fin-Body interference self.clalpha_multiple_fins = ( self.fin_num_correction(self.n) * self.lift_interference_factor @@ -173,19 +174,12 @@ def evaluate_lift_coefficient(self): ) # Function of mach number self.clalpha_multiple_fins.set_inputs("Mach") self.clalpha_multiple_fins.set_outputs( - f"Lift coefficient derivative for {self.n:.0f} fins" + f"Normal-force coefficient derivative for {self.n:.0f} fins" ) self.clalpha = self.clalpha_multiple_fins - # Cl = clalpha * alpha - self.cl = Function( - lambda alpha, mach: alpha * self.clalpha_multiple_fins(mach), - ["Alpha (rad)", "Mach"], - "Lift coefficient", - ) - - return self.cl + return self.clalpha def evaluate_roll_parameters(self): """Calculates and returns the fin set's roll coefficients. @@ -282,16 +276,14 @@ def to_dict(self, **kwargs): } if kwargs.get("include_outputs", False): - cl = self.cl + clalpha = self.clalpha if kwargs.get("discretize", False): - cl = cl.set_discrete( - (-np.pi / 6, 0), (np.pi / 6, 2), (10, 10), mutate_self=False - ) + clalpha = clalpha.set_discrete(0, 4, 50) data.update( { "cp": self.cp, - "cl": cl, + "clalpha": clalpha, "roll_parameters": self.roll_parameters, "rocket_diameter": self.rocket_diameter, "diameter": self.rocket_diameter, diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py index bf6565010..eedb4b76f 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fin.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py @@ -72,11 +72,12 @@ class FreeFormFin(Fin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. FreeFormFin.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. FreeFormFin.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. FreeFormFin.mac_length : float Mean aerodynamic chord length of the fin set. FreeFormFin.mac_lead : float diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fins.py b/rocketpy/rocket/aero_surface/fins/free_form_fins.py index d7c7e9512..d6186e9eb 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fins.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fins.py @@ -73,11 +73,12 @@ class FreeFormFins(Fins): Fin set local center of pressure z coordinate. Has units of length and is given in meters. FreeFormFins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. FreeFormFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. FreeFormFins.mac_length : float Mean aerodynamic chord length of the fin set. FreeFormFins.mac_lead : float diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py index 2c9adea58..dfffa4a83 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py @@ -82,11 +82,12 @@ class TrapezoidalFins(Fins): Fin set local center of pressure z coordinate. Has units of length and is given in meters. TrapezoidalFins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. TrapezoidalFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. """ def __init__( diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index d9f6fe82a..eb1dfac30 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,4 +1,5 @@ import copy +import inspect import math import numpy as np @@ -13,18 +14,91 @@ ) +def _as_function(func, independent_vars, name): + """Wrap a variadic callable as a :class:`Function` over ``independent_vars``. + + ``Function`` reads its domain dimension from the callable's parameter count, + so a variadic wrapper is given an explicit signature to advertise one + parameter per independent variable. + """ + func.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in independent_vars + ) + return Function(func, list(independent_vars), [name]) + + +def wind_to_body_coefficients(c_lift, c_drag, c_side, independent_vars): + """Rotate wind-frame force coefficients into the body frame. + + Given the lift, drag and side-force coefficients (each callable over the + surface's independent-variable tuple, with the angle of attack and sideslip + as the first two variables), return the body-frame normal, side and axial + coefficients ``(cN, cY, cA)`` as :class:`Function`s over the same variables. + """ + lift, drag, side = c_lift.get_value_opt, c_drag.get_value_opt, c_side.get_value_opt + + def normal(*args): + alpha, beta = args[0], args[1] + transverse = math.sin(beta) * side(*args) + math.cos(beta) * drag(*args) + return math.cos(alpha) * lift(*args) + math.sin(alpha) * transverse + + def yaw_side(*args): + beta = args[1] + return math.cos(beta) * side(*args) - math.sin(beta) * drag(*args) + + def axial(*args): + alpha, beta = args[0], args[1] + transverse = math.sin(beta) * side(*args) + math.cos(beta) * drag(*args) + return -math.sin(alpha) * lift(*args) + math.cos(alpha) * transverse + + return ( + _as_function(normal, independent_vars, "cN"), + _as_function(yaw_side, independent_vars, "cY"), + _as_function(axial, independent_vars, "cA"), + ) + + +def body_to_wind_coefficients(c_normal, c_side, c_axial, independent_vars): + """Rotate body-frame force coefficients into the wind frame. + + Inverse of :func:`wind_to_body_coefficients`: given the body-frame normal, + side and axial coefficients, return the wind-frame lift, drag and + side-force coefficients ``(cL, cD, cQ)`` as :class:`Function`s. + """ + normal = c_normal.get_value_opt + side = c_side.get_value_opt + axial = c_axial.get_value_opt + + def lift(*args): + alpha = args[0] + return math.cos(alpha) * normal(*args) - math.sin(alpha) * axial(*args) + + def drag(*args): + alpha, beta = args[0], args[1] + longitudinal = math.sin(alpha) * normal(*args) + math.cos(alpha) * axial(*args) + return -math.sin(beta) * side(*args) + math.cos(beta) * longitudinal + + def yaw_side(*args): + alpha, beta = args[0], args[1] + longitudinal = math.sin(alpha) * normal(*args) + math.cos(alpha) * axial(*args) + return math.cos(beta) * side(*args) + math.sin(beta) * longitudinal + + return ( + _as_function(lift, independent_vars, "cL"), + _as_function(drag, independent_vars, "cD"), + _as_function(yaw_side, independent_vars, "cQ"), + ) + + class GenericSurface: """Defines a generic aerodynamic surface with custom force and moment coefficients. The coefficients can be nonlinear functions of the angle of attack, sideslip angle, Mach number, Reynolds number, pitch rate, yaw rate and roll rate.""" - #: Whether this surface contributes identically to the pitch and yaw planes - #: *by construction*. Conservatively ``False`` for a generic surface (its - #: coefficients may differ between planes); the built-in axisymmetric - #: surfaces override it to ``True``. The rocket uses it to skip the numeric - #: pitch/yaw axisymmetry check when every surface is symmetric by - #: construction. + # Whether this surface contributes identically to the pitch and yaw planes. + # ``False`` for a generic surface (its coefficients may differ between planes) is_axisymmetric = False def __init__( @@ -35,6 +109,9 @@ def __init__( center_of_pressure=(0, 0, 0), name="Generic Surface", unsteady_aero=False, + interpolation=None, + extrapolation=None, + force_convention=None, ): """Create a generic aerodynamic surface, defined by its aerodynamic coefficients. This surface is used to model any aerodynamic surface @@ -49,6 +126,12 @@ def __init__( "reynolds", "pitch_rate", "yaw_rate" and "roll_rate". The independent variable columns can be provided in any order. + When ``unsteady_aero`` is True, the coefficients may additionally be + functions of the flow-angle rates "alpha_dot" and "beta_dot", which are + appended (in that order) after "roll_rate": callables must accept the + two extra trailing arguments and CSV files may include "alpha_dot" and + "beta_dot" columns. + The angular-rate inputs ("pitch_rate", "yaw_rate", "roll_rate") are the conventional **non-dimensional reduced rates**, ``q* = q * L_ref / (2 * V)`` (and likewise for ``r``/``p``), matching how published and tool-generated @@ -69,57 +152,77 @@ def __init__( Reference length of the aerodynamic surface. Has the unit of meters. Commonly defined as the rocket's diameter. coefficients: dict - List of coefficients. If a coefficient is omitted, it is set to 0. - The valid coefficients are:\n - cL: str, callable, optional - Lift coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n - cQ: str, callable, optional - Side force coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n - cD: str, callable, optional - Drag coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + The six force and moment coefficients, by name. Any you leave out are + set to 0. Each one can be a constant number, a function of the flow + variables, a list of data points, or a path to a CSV file. By default + the force coefficients are the body-frame ones (see + ``force_convention``); the wind-frame names ``cL``/``cQ``/``cD`` are + also accepted. The coefficients are:\n + cN: str, callable, optional + Normal force coefficient (body frame). Default is 0.\n + cY: str, callable, optional + Side force coefficient (body frame). Default is 0.\n + cA: str, callable, optional + Axial force coefficient (body frame). Default is 0.\n cm: str, callable, optional - Pitch moment coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + Pitch moment coefficient. Default is 0.\n cn: str, callable, optional - Yaw moment coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + Yaw moment coefficient. Default is 0.\n cl: str, callable, optional - Roll moment coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + Roll moment coefficient. Default is 0.\n center_of_pressure : tuple, list, optional Application point of the aerodynamic forces and moments. The center of pressure is defined in the local coordinate system of the aerodynamic surface. The default value is (0, 0, 0). name : str, optional - Name of the aerodynamic surface. Default is 'GenericSurface'. + Name of the aerodynamic surface. Default is 'Generic Surface'. unsteady_aero : bool, optional If True, the coefficients additionally depend on the time derivatives of the flow angles, and ``alpha_dot`` and ``beta_dot`` are appended (in that order) to the independent variables. CSV files may then include "alpha_dot"/"beta_dot" columns, and callables must - accept the two extra trailing arguments. The simulation supplies 0 - for these unless it computes them, so existing coefficient tables are - unaffected. Default is False. + accept the two extra trailing arguments. Default is False. + interpolation : str or dict, optional + How tabulated coefficients interpolate between points. The accepted + methods depend on the coefficient's dimensionality: a 1-D table + (e.g. a Mach-only curve) accepts ``"linear"``, ``"akima"``, + ``"spline"`` and ``"polynomial"``; a multi-dimensional scattered + table accepts ``"linear"``, ``"shepard"`` and ``"rbf"``; and a + multi-dimensional table on a regular Cartesian grid accepts + ``"linear"``, ``"nearest"``, ``"slinear"``, ``"cubic"``, + ``"quintic"`` and ``"pchip"`` (with ``"spline"`` mapped to + ``"cubic"`` and ``"akima"`` to ``"pchip"``). Accepts either a simple + string or a dict keyed by coefficient name (names left out fall back + to the default). ``None`` (the default) uses ``"linear"`` for tables + built here and keeps a pre-built ``Function``'s own setting. + extrapolation : str or dict, optional + How tabulated coefficients behave outside their data range: + ``"constant"`` holds the value at the nearest data edge, + ``"natural"`` keeps following the curve, and ``"zero"`` returns 0. + Accepts either a simple string or a dict keyed by coefficient name + (names left out fall back to the default). ``None`` (the default) + uses ``"constant"`` for tables built here and keeps whatever a + pre-built ``Function`` already carries. Only affects tabulated + sources (constants and callables are evaluated directly). + force_convention : str, optional + The frame your force coefficients are given in. ``"wind"`` for the + aerodynamic-frame coefficients ``cL`` (lift), ``cQ`` (side) and + ``cD`` (drag); ``"body"`` for the body-frame coefficients ``cN`` + (normal), ``cY`` (side) and ``cA`` (axial), the convention used by + Missile DATCOM, wind tunnels and Barrowman. The moment coefficients + (``cm``, ``cn``, ``cl``) are the same in both. ``None`` (the default) + infers the frame from the coefficient names you pass. Whichever frame + you use, all nine coefficients are available as attributes afterwards + (the other frame is computed on demand). """ - # The independent variables of the coefficients are derived (see the - # ``independent_vars`` property) from ``unsteady_aero`` and, for - # subclasses, ``control_variables``. When ``unsteady_aero`` is enabled, - # the time-derivatives of the flow angles (``alpha_dot``, ``beta_dot``) - # become extra axes (defaulting to 0 at runtime, so existing tables are - # unaffected). Subclasses that add externally-supplied axes set - # ``control_variables`` before calling ``super().__init__``. self._unsteady_aero = unsteady_aero # Externally-supplied axes (e.g. control deflections). Subclasses set - # this before ``super().__init__``; defaults to none for plain surfaces. + # this before ``super().__init__``. Defaults to none for plain surfaces. self.control_variables = getattr(self, "control_variables", ()) # Ordered independent variables accepted by every coefficient: the seven # base axes, plus ``alpha_dot``/``beta_dot`` when ``unsteady_aero`` is - # enabled (integrator-supplied), plus any ``control_variables`` a - # subclass appended (externally supplied). Fixed at construction. + # enabled, plus any ``control_variables`` self.independent_vars = build_independent_vars( self._unsteady_aero, self.control_variables ) @@ -133,9 +236,22 @@ def __init__( self.cpz = center_of_pressure[2] self.name = name - self._rotation_surface_to_body = Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + self._rotation_surface_to_body = self._default_surface_rotation() default_coefficients = self._get_default_coefficients() + self.force_convention = self._resolve_force_convention( + coefficients, force_convention + ) + # The wind->body conversion only applies to surfaces whose coefficients + # are the full body-frame forces (cN/cY/cA). The linear model uses + # coefficient derivatives (cN_alpha, ...) whose frame is fixed by name. + # A non-dict input falls through to _check_coefficients, which rejects it. + if ( + self.force_convention == "wind" + and "cN" in default_coefficients + and isinstance(coefficients, dict) + ): + coefficients = self._wind_input_to_body(coefficients) self._check_coefficients(coefficients, default_coefficients) coefficients = self._complete_coefficients(coefficients, default_coefficients) for coeff, coeff_value in coefficients.items(): @@ -144,28 +260,60 @@ def __init__( unsteady_aero=self._unsteady_aero, control_variables=self.control_variables, name=coeff, + extrapolation=self._coefficient_option(extrapolation, coeff), + interpolation=self._coefficient_option(interpolation, coeff), ) setattr(self, coeff, value) self.evaluate_coefficients() - self._evaluate_derived_coefficients() + self._evaluate_stability_derivatives() # Reporting layers. Subclasses override these with their own (more # specific) prints/plots after calling ``super().__init__``. self.prints = _GenericSurfacePrints(self) self.plots = _GenericSurfacePlots(self) + def _default_surface_rotation(self): + """Rotation from the surface-local frame to the body frame. It is applied + to the :attr:`force_application_point` when the rocket locates each + surface's center of pressure relative to the center of dry mass. A plain + generic surface takes its center of pressure as already body-aligned + (the identity); geometry-defined (Barrowman) surfaces override this. + """ + return Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + @property def force_application_point(self): """Local point (surface frame) at which the resultant force is applied - when transporting its moment to the rocket's center of dry mass. For a - plain generic surface this is simply the center of pressure ``self.cp``; - the residual couple is carried by the ``cm``/``cn``/``cl`` coefficients. - Barrowman subclasses override this to the origin, because they fold the - whole cp offset into the moment coefficients instead. + when transporting its moment to the rocket's center of dry mass. This is + the center of pressure ``self.cp``; any residual couple is carried by the + ``cm``/``cn``/``cl`` coefficients. """ return Vector([self.cpx, self.cpy, self.cpz]) + @property + def cL(self): # pylint: disable=invalid-name + """Wind-frame lift coefficient, as a :class:`Function` of the surface's + independent variables. Derived from the canonical body-frame ``cN``, + ``cY`` and ``cA`` by the angle-of-attack/sideslip rotation.""" + return body_to_wind_coefficients( + self.cN, self.cY, self.cA, self.independent_vars + )[0] + + @property + def cD(self): # pylint: disable=invalid-name + """Wind-frame drag coefficient (derived from ``cN``/``cY``/``cA``).""" + return body_to_wind_coefficients( + self.cN, self.cY, self.cA, self.independent_vars + )[1] + + @property + def cQ(self): # pylint: disable=invalid-name + """Wind-frame side-force coefficient (derived from ``cN``/``cY``/``cA``).""" + return body_to_wind_coefficients( + self.cN, self.cY, self.cA, self.independent_vars + )[2] + def info(self): """Prints a summary of the surface's geometry and aerodynamic coefficients. Subclasses override this with surface-specific summaries. @@ -200,79 +348,91 @@ def evaluate_coefficients(self): None """ - def _evaluate_derived_coefficients(self): - """Build the mach-only diagnostic accessors used by the rocket's - center-of-pressure / stability-margin computation, for both the pitch - and the yaw plane. + def _evaluate_stability_derivatives(self): + """Compute the coefficient derivatives used for stability and store them + as the ``cN_alpha``, ``cm_alpha``, ``cY_beta`` and ``cn_beta`` + attributes, then build the center-of-pressure accessors from them. - These reconstruct, at the linearization point ``alpha = beta = 0`` with - zero rates, each plane's force-curve slope and the location of its - center of pressure. The center of pressure combines the surface's - declared local ``cpz`` with the offset implied by its moment - coefficient (the two representations are interchangeable; - ``cpz_eff = cpz - (dc_moment/dangle)/(dc_force/dangle) * L_ref``): - - - pitch plane: ``lift_coefficient_derivative`` (``dcL/dalpha``) and - ``center_of_pressure_z`` (from ``cm``); - - yaw plane: ``side_coefficient_derivative`` and - ``center_of_pressure_z_yaw`` (from ``cn``). + A plain generic surface recovers each derivative from its body-frame + force and moment coefficients by numerical differentiation at + ``alpha = beta = 0`` with zero rates. The Barrowman surfaces instead set + these four attributes directly from geometry and only reuse + :meth:`_set_stability_accessors` (see the :class:`LinearGenericSurface` + override). Returns ------- None """ - cL_alpha = self._partial_slope(self.cL, axis="alpha") - cm_alpha = self._partial_slope(self.cm, axis="alpha") - cQ_beta = self._partial_slope(self.cQ, axis="beta") - cn_beta = self._partial_slope(self.cn, axis="beta") - self._set_derived_cp_accessors(cL_alpha, cm_alpha, cQ_beta, cn_beta) - - def _set_derived_cp_accessors(self, cL_alpha, cm_alpha, cQ_beta, cn_beta): - """Store the pitch- and yaw-plane diagnostic accessors as mach-only - ``Function``s, guarding the moment/force division for zero-force - surfaces (which then drop out of the force-weighted cp average). + self.cN_alpha = self._derivative_coefficient(self.cN, "alpha", "cN_alpha") + self.cm_alpha = self._derivative_coefficient(self.cm, "alpha", "cm_alpha") + self.cY_beta = self._derivative_coefficient(self.cY, "beta", "cY_beta") + self.cn_beta = self._derivative_coefficient(self.cn, "beta", "cn_beta") + self._set_stability_accessors() + + def _derivative_coefficient(self, coefficient, axis, name): + """Numerically differentiate ``coefficient`` along ``axis`` at the + linearization point and wrap the Mach-only result as an + :class:`AeroCoefficient`, so every surface exposes ``cN_alpha`` and its + siblings in the same form (a coefficient callable over the full + argument tuple that depends only on Mach). Parameters ---------- - cL_alpha : Function - Pitch-plane normal-force slope ``dcL/dalpha`` vs. mach. - cm_alpha : Function - Pitch-moment slope ``dcm/dalpha`` vs. mach. - cQ_beta : Function - Yaw-plane side-force slope ``dcQ/dbeta`` vs. mach. - cn_beta : Function - Yaw-moment slope ``dcn/dbeta`` vs. mach. + coefficient : AeroCoefficient + The force or moment coefficient to differentiate. + axis : str + Either ``"alpha"`` or ``"beta"``. + name : str + Name of the resulting derivative coefficient. + + Returns + ------- + AeroCoefficient + The Mach-only derivative ``d(coefficient)/d(axis)``. + """ + slope = self._partial_slope(coefficient, axis=axis) + return AeroCoefficient( + slope, + depends_on=("mach",), + unsteady_aero=self._unsteady_aero, + control_variables=self.control_variables, + name=name, + ) + + def _set_stability_accessors(self): + """Build the pitch- and yaw-plane center-of-pressure accessors from the + stored coefficient derivatives (``cN_alpha``/``cm_alpha`` and + ``cY_beta``/``cn_beta``), each evaluated at ``alpha = beta = 0`` with + zero rates. + + Each accessor is a Mach-only :class:`Function` giving the surface's + center of pressure along the body z-axis. It combines the surface's + local application point with the offset implied by its moment + coefficient (``cp = application point - (moment slope / force slope) * + L_ref``). When a surface produces no force at some Mach the center of + pressure is undefined, so it falls back to the geometric application + point and drops out of the force-weighted average. + + Returns + ------- + None """ reference_length = self.reference_length local_cpz = self.force_application_point[2] - def _cp_z(force_slope, moment_slope): - # Recover the center of pressure from a force slope and its matching - # moment slope, as a Function of Mach. + def _cp_z(force_coeff, moment_coeff): def cp_z(mach): - slope = force_slope.get_value_opt(mach) - # No force at this Mach -> the cp is undefined; fall back to the - # geometric application point so this surface contributes zero - # weight to the force-weighted cp average. + slope = force_coeff.get_value_opt(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0) if slope == 0: return local_cpz - # cp = application point - (moment slope / force slope) * L_ref. - return ( - local_cpz - - moment_slope.get_value_opt(mach) / slope * reference_length - ) + moment = moment_coeff.get_value_opt(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0) + return local_cpz - moment / slope * reference_length return Function(cp_z, "Mach", "Center of pressure to local origin (m)") - # Pitch plane. - self.lift_coefficient_derivative = cL_alpha - self.center_of_pressure_z = _cp_z(cL_alpha, cm_alpha) - - # Yaw plane. The side-force slope is sign-adjusted (``-cQ_beta``) so - # that an axisymmetric surface yields the same signed weight as the - # pitch plane, making the two planes' margins coincide when symmetric. - self.side_coefficient_derivative = -cQ_beta - self.center_of_pressure_z_yaw = _cp_z(cQ_beta, cn_beta) + self.center_of_pressure_z = _cp_z(self.cN_alpha, self.cm_alpha) + self.center_of_pressure_z_yaw = _cp_z(self.cY_beta, self.cn_beta) def _partial_slope(self, coefficient, axis): """Partial derivative ``d(coefficient)/d(axis)`` at ``alpha = beta = 0`` @@ -317,6 +477,85 @@ def slope(mach): return Function(slope, "Mach", "Coefficient derivative") + @staticmethod + def _coefficient_option(option, coeff_name): + """Resolve a per-coefficient interpolation/extrapolation setting. + + ``option`` may be a single value applied to every coefficient, a dict + mapping coefficient names to values (coefficients absent from the dict + fall back to the ``AeroCoefficient`` default), or ``None``. + + Parameters + ---------- + option : str, dict, or None + The interpolation/extrapolation argument passed to ``__init__``. + coeff_name : str + Name of the coefficient being built (e.g. ``"cD"``, ``"cm_alpha"``). + + Returns + ------- + str or None + The value to forward to :class:`AeroCoefficient` for this coefficient. + """ + if isinstance(option, dict): + return option.get(coeff_name) + return option + + # Force-coefficient names in each frame. Moments (cm/cn/cl) are frame-shared. + _WIND_FORCE_NAMES = ("cL", "cQ", "cD") + _BODY_FORCE_NAMES = ("cN", "cY", "cA") + + def _resolve_force_convention(self, coefficients, force_convention): + """Decide whether the input force coefficients are given in the wind + frame (``cL``/``cQ``/``cD``) or the body frame (``cN``/``cY``/``cA``). + + When ``force_convention`` is ``None`` the frame is inferred from the + coefficient names; mixing the two frames is rejected. + """ + keys = set(coefficients) + has_wind = bool(keys & set(self._WIND_FORCE_NAMES)) + has_body = bool(keys & set(self._BODY_FORCE_NAMES)) + if force_convention is None: + if has_wind and has_body: + raise ValueError( + "Mixed wind (cL/cQ/cD) and body (cN/cY/cA) force " + "coefficients; pass force_convention='wind' or 'body'." + ) + return "body" if has_body else "wind" + if force_convention not in ("wind", "body"): + raise ValueError( + f"force_convention must be 'wind' or 'body', got {force_convention!r}." + ) + return force_convention + + def _wind_input_to_body(self, coefficients): + """Convert a wind-frame force-coefficient input (``cL``/``cQ``/``cD``) + into the canonical body-frame coefficients (``cN``/``cY``/``cA``), + leaving the moment coefficients untouched.""" + wind = {} + passthrough = {} + for name, value in coefficients.items(): + if name in self._WIND_FORCE_NAMES: + wind[name] = value + else: + passthrough[name] = value + + def as_coefficient(source, name): + return AeroCoefficient( + source, + unsteady_aero=self._unsteady_aero, + control_variables=self.control_variables, + name=name, + ) + + c_normal, c_yaw, c_axial = wind_to_body_coefficients( + as_coefficient(wind.get("cL", 0), "cL"), + as_coefficient(wind.get("cD", 0), "cD"), + as_coefficient(wind.get("cQ", 0), "cQ"), + self.independent_vars, + ) + return {"cN": c_normal, "cY": c_yaw, "cA": c_axial, **passthrough} + def _get_default_coefficients(self): """Returns default coefficients @@ -327,9 +566,9 @@ def _get_default_coefficients(self): are the default values. """ default_coefficients = { - "cL": 0, - "cQ": 0, - "cD": 0, + "cN": 0, + "cY": 0, + "cA": 0, "cm": 0, "cn": 0, "cl": 0, @@ -422,12 +661,18 @@ def _compute_from_coefficients( Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. + alpha_dot : float, optional + Non-dimensional angle-of-attack rate, used by unsteady surfaces. + Defaults to 0. + beta_dot : float, optional + Non-dimensional sideslip-angle rate, used by unsteady surfaces. + Defaults to 0. Returns ------- tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. + The body-frame force components ``(R1, R2, R3)`` and the moments + ``(pitch, yaw, roll)``. """ # Precompute common values dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area @@ -448,17 +693,21 @@ def _compute_from_coefficients( beta_dot, ) - # Compute aerodynamic forces - lift = dyn_pressure_area * self.cL(*args) - side = dyn_pressure_area * self.cQ(*args) - drag = dyn_pressure_area * self.cD(*args) + # Body-frame force components straight from the body-frame coefficients + # (normal cN, side cY, axial cA); no wind-to-body rotation needed. + normal = dyn_pressure_area * self.cN(*args) + yaw_side = dyn_pressure_area * self.cY(*args) + axial = dyn_pressure_area * self.cA(*args) + r1 = yaw_side + r2 = -normal + r3 = -axial # Compute aerodynamic moments pitch = dyn_pressure_area_length * self.cm(*args) yaw = dyn_pressure_area_length * self.cn(*args) roll = dyn_pressure_area_length * self.cl(*args) - return lift, side, drag, pitch, yaw, roll + return r1, r2, r3, pitch, yaw, roll def _coefficient_arguments( self, @@ -524,6 +773,12 @@ def compute_forces_and_moments( z : float Altitude of the surface, used to evaluate ``density`` and ``dynamic_viscosity``. + alpha_dot : float, optional + Non-dimensional angle-of-attack rate, used by unsteady surfaces. + Defaults to 0. + beta_dot : float, optional + Non-dimensional sideslip-angle rate, used by unsteady surfaces. + Defaults to 0. Returns ------- @@ -549,18 +804,16 @@ def compute_forces_and_moments( beta = np.arctan2(stream_velocity[0], stream_velocity[2]) # Non-dimensionalize the body angular rates into the conventional reduced - # rates (e.g. ``q* = q * L_ref / (2 * V)``) before evaluating the - # coefficients, so coefficient tables follow the standard aerotable - # convention (Missile DATCOM, OpenVSP, CFD/wind-tunnel data tabulate rate - # derivatives against the reduced rates). The factor is 0 at zero airspeed - # (pad/static) to avoid division by zero; there is no aerodynamic damping - # there anyway. + # rates (e.g. ``q* = q * L_ref / (2 * V)``). reduced_rate_factor = ( self.reference_length / (2 * stream_speed) if stream_speed > 0 else 0.0 ) - # Compute aerodynamic forces and moments - lift, side, drag, pitch, yaw, roll = self._compute_from_coefficients( + # Body-frame force components and moments straight from the body-frame + # coefficients (no wind-to-body rotation: the coefficients already live + # in the body frame). ``alpha``/``beta`` are still passed to the + # coefficients, they just no longer rotate the force. + R1, R2, R3, pitch, yaw, roll = self._compute_from_coefficients( rho, stream_speed, alpha, @@ -574,25 +827,6 @@ def compute_forces_and_moments( beta_dot, ) - # Conversion from the aerodynamic frame to the body frame. This is the - # direction cosine matrix (DCM) that expresses the aerodynamic-frame - # force components in the body frame, i.e. rotations by ``-alpha`` about - # x and ``+beta`` about y. - rotation_matrix = Matrix( - [ - [1, 0, 0], - [0, math.cos(alpha), math.sin(alpha)], - [0, -math.sin(alpha), math.cos(alpha)], - ] - ) @ Matrix( - [ - [math.cos(beta), 0, math.sin(beta)], - [0, 1, 0], - [-math.sin(beta), 0, math.cos(beta)], - ] - ) - R1, R2, R3 = rotation_matrix @ Vector([side, -lift, -drag]) - # Dislocation of the aerodynamic application point to CDM M1, M2, M3 = Vector([pitch, yaw, roll]) + (cp ^ Vector([R1, R2, R3])) diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index 8bf2476ef..ee92f5cf2 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -4,10 +4,14 @@ from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +# TODO: review note: ControllableGenericSurface should also be able to be modelled +# based on LinearGenericSurface.... class LinearGenericSurface(GenericSurface): - """Class that defines a generic linear aerodynamic surface. This class is - used to define aerodynamic surfaces that have aerodynamic coefficients - defined as linear functions of the coefficients derivatives.""" + """An aerodynamic surface whose forces and moments vary linearly with the + flow angles and the rotation rates. Instead of full coefficient tables, you + give the coefficient *derivatives* (slopes) -- for example how much the normal + force changes per radian of angle of attack -- and the surface adds them up + linearly.""" def __init__( self, @@ -16,6 +20,8 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Linear Surface", + interpolation=None, + extrapolation=None, ): """Create a generic linear aerodynamic surface, defined by its aerodynamic coefficients derivatives. This surface is used to model any @@ -42,59 +48,67 @@ def __init__( Reference length of the aerodynamic surface. Has the unit of meters. Commonly defined as the rocket's diameter. coefficients: dict, optional - List of coefficients. If a coefficient is omitted, it is set to 0. - The valid coefficients are:\n - cL_0: callable, str, optional - Coefficient of lift at zero angle of attack. Default is 0.\n - cL_alpha: callable, str, optional - Coefficient of lift derivative with respect to angle of attack. + The coefficient derivatives (slopes), by name. Any you leave out are + set to 0. Each one can be a constant, a function, or a path to a data + file, and says how one force or moment coefficient changes with one + variable (angle in radians, or a non-dimensional rotation rate). The + names follow the pattern ``_``: the coefficient + is normal force ``cN``, side force ``cY``, axial force ``cA``, pitch moment ``cm``, + yaw moment ``cn`` or roll moment ``cl``; the variable is ``0`` (the + value at zero angle of attack, zero sideslip and zero rates), + ``alpha``, ``beta``, ``p`` (roll rate), ``q`` (pitch rate) or ``r`` + (yaw rate). The full list is:\n + cN_0: callable, str, optional + Coefficient of normal force at zero angle of attack. Default is 0.\n + cN_alpha: callable, str, optional + Coefficient of normal force derivative with respect to angle of attack. Default is 0.\n - cL_beta: callable, str, optional - Coefficient of lift derivative with respect to sideslip angle. + cN_beta: callable, str, optional + Coefficient of normal force derivative with respect to sideslip angle. Default is 0.\n - cL_p: callable, str, optional - Coefficient of lift derivative with respect to roll rate. + cN_p: callable, str, optional + Coefficient of normal force derivative with respect to roll rate. Default is 0.\n - cL_q: callable, str, optional - Coefficient of lift derivative with respect to pitch rate. + cN_q: callable, str, optional + Coefficient of normal force derivative with respect to pitch rate. Default is 0.\n - cL_r: callable, str, optional - Coefficient of lift derivative with respect to yaw rate. + cN_r: callable, str, optional + Coefficient of normal force derivative with respect to yaw rate. Default is 0.\n - cQ_0: callable, str, optional + cY_0: callable, str, optional Coefficient of side force at zero angle of attack. Default is 0.\n - cQ_alpha: callable, str, optional + cY_alpha: callable, str, optional Coefficient of side force derivative with respect to angle of attack. Default is 0.\n - cQ_beta: callable, str, optional + cY_beta: callable, str, optional Coefficient of side force derivative with respect to sideslip angle. Default is 0.\n - cQ_p: callable, str, optional + cY_p: callable, str, optional Coefficient of side force derivative with respect to roll rate. Default is 0.\n - cQ_q: callable, str, optional + cY_q: callable, str, optional Coefficient of side force derivative with respect to pitch rate. Default is 0.\n - cQ_r: callable, str, optional + cY_r: callable, str, optional Coefficient of side force derivative with respect to yaw rate. Default is 0.\n - cD_0: callable, str, optional - Coefficient of drag at zero angle of attack. Default is 0.\n - cD_alpha: callable, str, optional - Coefficient of drag derivative with respect to angle of attack. + cA_0: callable, str, optional + Coefficient of axial force at zero angle of attack. Default is 0.\n + cA_alpha: callable, str, optional + Coefficient of axial force derivative with respect to angle of attack. Default is 0.\n - cD_beta: callable, str, optional - Coefficient of drag derivative with respect to sideslip angle. + cA_beta: callable, str, optional + Coefficient of axial force derivative with respect to sideslip angle. Default is 0.\n - cD_p: callable, str, optional - Coefficient of drag derivative with respect to roll rate. + cA_p: callable, str, optional + Coefficient of axial force derivative with respect to roll rate. Default is 0.\n - cD_q: callable, str, optional - Coefficient of drag derivative with respect to pitch rate. + cA_q: callable, str, optional + Coefficient of axial force derivative with respect to pitch rate. Default is 0.\n - cD_r: callable, str, optional - Coefficient of drag derivative with respect to yaw rate. + cA_r: callable, str, optional + Coefficient of axial force derivative with respect to yaw rate. Default is 0.\n cm_0: callable, str, optional Coefficient of pitch moment at zero angle of attack. @@ -154,8 +168,31 @@ def __init__( Application point of the aerodynamic forces and moments. The center of pressure is defined in the local coordinate system of the aerodynamic surface. The default value is (0, 0, 0). - name : str - Name of the aerodynamic surface. Default is 'GenericSurface'. + name : str, optional + Name of the aerodynamic surface. Default is 'Generic Linear + Surface'. + interpolation : str or dict, optional + How tabulated coefficient derivatives interpolate between points. + The accepted methods depend on the coefficient's dimensionality: a + 1-D table (e.g. a Mach-only curve) accepts ``"linear"``, ``"akima"``, + ``"spline"`` and ``"polynomial"``; a multi-dimensional scattered + table accepts ``"linear"``, ``"shepard"`` and ``"rbf"``; and a + multi-dimensional table on a regular Cartesian grid accepts + ``"linear"``, ``"nearest"``, ``"slinear"``, ``"cubic"``, + ``"quintic"`` and ``"pchip"`` (with ``"spline"`` mapped to + ``"cubic"`` and ``"akima"`` to ``"pchip"``). Accepts either a simple + string or a dict keyed by coefficient name (names left out fall back + to the default). ``None`` (the default) uses ``"linear"`` for tables + built here and keeps a pre-built ``Function``'s own setting. + extrapolation : str or dict, optional + How tabulated coefficient derivatives behave outside their data + range: ``"constant"`` holds the value at the nearest data edge, + ``"natural"`` keeps following the curve, and ``"zero"`` returns 0. + Accepts either a simple string or a dict keyed by coefficient name + (names left out fall back to the default). ``None`` (the default) + uses ``"constant"`` for tables built here and keeps whatever a + pre-built ``Function`` already carries. Only affects tabulated + sources (constants and callables are evaluated directly). """ super().__init__( @@ -164,6 +201,8 @@ def __init__( coefficients=coefficients, center_of_pressure=center_of_pressure, name=name, + extrapolation=extrapolation, + interpolation=interpolation, ) self.compute_all_coefficients() @@ -171,28 +210,17 @@ def __init__( self.prints = _LinearGenericSurfacePrints(self) self.plots = _LinearGenericSurfacePlots(self) - def _evaluate_derived_coefficients(self): - """Exact override of the diagnostic cp accessors. The linear model - already exposes the forcing derivatives ``cL_alpha``/``cm_alpha`` (pitch) - and ``cQ_beta``/``cn_beta`` (yaw), so the slopes are read directly - (frozen at zero alpha/beta/rates) instead of being recovered by - numerical differentiation. Damping derivatives (``_p/_q/_r``) are - intentionally excluded from the stability cp. - """ + def _evaluate_stability_derivatives(self): + """Build the center-of-pressure accessors for the linear model. - def _at_zero(coefficient, name): - return Function( - lambda mach: coefficient(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0), - "Mach", - name, - ) - - self._set_derived_cp_accessors( - _at_zero(self.cL_alpha, "cL_alpha"), - _at_zero(self.cm_alpha, "cm_alpha"), - _at_zero(self.cQ_beta, "cQ_beta"), - _at_zero(self.cn_beta, "cn_beta"), - ) + The linear model already stores the coefficient derivatives + ``cN_alpha``/``cm_alpha`` (pitch) and ``cY_beta``/``cn_beta`` (yaw) as + the surface's own coefficients, so there is nothing to differentiate: + :meth:`_set_stability_accessors` reads them directly (evaluated at zero + alpha/beta and zero rates). Damping derivatives (``_p/_q/_r``) are + intentionally excluded from the stability center of pressure. + """ + self._set_stability_accessors() def _get_default_coefficients(self): """Returns default coefficients @@ -204,24 +232,24 @@ def _get_default_coefficients(self): are the default values. """ default_coefficients = { - "cL_0": 0, - "cL_alpha": 0, - "cL_beta": 0, - "cL_p": 0, - "cL_q": 0, - "cL_r": 0, - "cQ_0": 0, - "cQ_alpha": 0, - "cQ_beta": 0, - "cQ_p": 0, - "cQ_q": 0, - "cQ_r": 0, - "cD_0": 0, - "cD_alpha": 0, - "cD_beta": 0, - "cD_p": 0, - "cD_q": 0, - "cD_r": 0, + "cN_0": 0, + "cN_alpha": 0, + "cN_beta": 0, + "cN_p": 0, + "cN_q": 0, + "cN_r": 0, + "cY_0": 0, + "cY_alpha": 0, + "cY_beta": 0, + "cY_p": 0, + "cY_q": 0, + "cY_r": 0, + "cA_0": 0, + "cA_alpha": 0, + "cA_beta": 0, + "cA_p": 0, + "cA_q": 0, + "cA_r": 0, "cm_0": 0, "cm_alpha": 0, "cm_beta": 0, @@ -262,6 +290,21 @@ def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): terms that are identically zero are skipped entirely. For a Barrowman surface each forcing coefficient has at most one non-zero derivative, so this typically collapses to a single source call (or to a constant 0). + + Parameters + ---------- + c_0 : AeroCoefficient + Zero-angle derivative (constant term). + c_alpha : AeroCoefficient + Derivative with respect to the angle of attack ``alpha``. + c_beta : AeroCoefficient + Derivative with respect to the sideslip angle ``beta``. + + Returns + ------- + Function + Coefficient as a function of the independent variables + ``(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)``. """ has_0 = not getattr(c_0, "is_zero_coefficient", False) has_alpha = not getattr(c_alpha, "is_zero_coefficient", False) @@ -311,6 +354,21 @@ def compute_damping_coefficient(self, c_p, c_q, c_r): non-zero terms (see :meth:`compute_forcing_coefficient`). For a Barrowman surface only ``cl_p`` (roll damping) is non-zero, so most damping coefficients collapse to a constant 0. + + Parameters + ---------- + c_p : AeroCoefficient + Derivative with respect to the roll rate ``roll_rate``. + c_q : AeroCoefficient + Derivative with respect to the pitch rate ``pitch_rate``. + c_r : AeroCoefficient + Derivative with respect to the yaw rate ``yaw_rate``. + + Returns + ------- + Function + Coefficient as a function of the independent variables + ``(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)``. """ has_p = not getattr(c_p, "is_zero_coefficient", False) has_q = not getattr(c_q, "is_zero_coefficient", False) @@ -360,20 +418,20 @@ def total_coefficient( def compute_all_coefficients(self): """Compute all the aerodynamic coefficients from the derivatives.""" # pylint: disable=invalid-name - self.cLf = self.compute_forcing_coefficient( - self.cL_0, self.cL_alpha, self.cL_beta + self.cNf = self.compute_forcing_coefficient( + self.cN_0, self.cN_alpha, self.cN_beta ) - self.cLd = self.compute_damping_coefficient(self.cL_p, self.cL_q, self.cL_r) + self.cNd = self.compute_damping_coefficient(self.cN_p, self.cN_q, self.cN_r) - self.cQf = self.compute_forcing_coefficient( - self.cQ_0, self.cQ_alpha, self.cQ_beta + self.cYf = self.compute_forcing_coefficient( + self.cY_0, self.cY_alpha, self.cY_beta ) - self.cQd = self.compute_damping_coefficient(self.cQ_p, self.cQ_q, self.cQ_r) + self.cYd = self.compute_damping_coefficient(self.cY_p, self.cY_q, self.cY_r) - self.cDf = self.compute_forcing_coefficient( - self.cD_0, self.cD_alpha, self.cD_beta + self.cAf = self.compute_forcing_coefficient( + self.cA_0, self.cA_alpha, self.cA_beta ) - self.cDd = self.compute_damping_coefficient(self.cD_p, self.cD_q, self.cD_r) + self.cAd = self.compute_damping_coefficient(self.cA_p, self.cA_q, self.cA_r) self.cmf = self.compute_forcing_coefficient( self.cm_0, self.cm_alpha, self.cm_beta @@ -390,11 +448,12 @@ def compute_all_coefficients(self): ) self.cld = self.compute_damping_coefficient(self.cl_p, self.cl_q, self.cl_r) - self.cL = self.cLf - self.cQ = self.cQf - self.cD = self.cDf + self.cN = self.cNf + self.cY = self.cYf + self.cA = self.cAf self.cm = self.cmf self.cn = self.cnf + self.cl = self.clf def _compute_from_coefficients( self, @@ -436,38 +495,38 @@ def _compute_from_coefficients( Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. + alpha_dot : float, optional + Non-dimensional angle-of-attack rate. Ignored by the linear model; + accepted for signature compatibility. Defaults to 0. + beta_dot : float, optional + Non-dimensional sideslip-angle rate. Ignored by the linear model; + accepted for signature compatibility. Defaults to 0. Returns ------- tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. + The body-frame force components ``(R1, R2, R3)`` and the moments + ``(pitch, yaw, roll)``. """ - # Precompute common values. The angular rates arrive already - # non-dimensionalized (reduced rates, e.g. ``q* = q * L_ref / (2 * V)``), - # so the rate-damping terms use the same dynamic-pressure scaling as the - # forcing terms: the ``L_ref / (2 * V)`` factor now lives in the rate - # itself, not in the scaling. (Algebraically identical to the previous - # ``0.5 * rho * V * A * L / 2`` damping scaling applied to raw rates.) + # Precompute common values dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area dyn_pressure_area_length = dyn_pressure_area * self.reference_length - - # Evaluate the composed coefficients through the fast, unvalidated - # ``get_value_opt`` path (the composed coefficients are callable-source - # Functions, so this calls the closure directly, skipping the per-call - # ``__call__``/``get_value`` argument validation in the hot loop). args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - # Compute aerodynamic forces (forcing + reduced-rate damping) - lift = dyn_pressure_area * ( - self.cLf.get_value_opt(*args) + self.cLd.get_value_opt(*args) + # Body-frame forces (forcing + reduced-rate damping), straight from the + # body-frame coefficients: normal cN, side cY, axial cA. + normal = dyn_pressure_area * ( + self.cNf.get_value_opt(*args) + self.cNd.get_value_opt(*args) ) - side = dyn_pressure_area * ( - self.cQf.get_value_opt(*args) + self.cQd.get_value_opt(*args) + yaw_side = dyn_pressure_area * ( + self.cYf.get_value_opt(*args) + self.cYd.get_value_opt(*args) ) - drag = dyn_pressure_area * ( - self.cDf.get_value_opt(*args) + self.cDd.get_value_opt(*args) + axial = dyn_pressure_area * ( + self.cAf.get_value_opt(*args) + self.cAd.get_value_opt(*args) ) + r1 = yaw_side + r2 = -normal + r3 = -axial # Compute aerodynamic moments (forcing + reduced-rate damping) pitch = dyn_pressure_area_length * ( @@ -480,4 +539,4 @@ def _compute_from_coefficients( self.clf.get_value_opt(*args) + self.cld.get_value_opt(*args) ) - return lift, side, drag, pitch, yaw, roll + return r1, r2, r3, pitch, yaw, roll diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py index a0c0507e7..7f17c159b 100644 --- a/rocketpy/rocket/aero_surface/nose_cone.py +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -65,11 +65,12 @@ class NoseCone(_BarrowmanSurface): Nose cone local center of pressure z coordinate. Has units of length and is given in meters. NoseCone.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. NoseCone.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. NoseCone.plots : plots.aero_surface_plots._NoseConePlots This contains all the plots methods. Use help(NoseCone.plots) to know more about it. @@ -476,12 +477,7 @@ def evaluate_lift_coefficient(self): self.clalpha = Function( lambda mach: 2 * self.radius_ratio**2, "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: self.clalpha(mach) * alpha, - ["Alpha (rad)", "Mach"], - "Cl", + f"Normal-force coefficient derivative for {self.name}", ) def evaluate_k(self): @@ -563,15 +559,10 @@ def to_dict(self, **kwargs): } if kwargs.get("include_outputs", False): clalpha = self.clalpha - cl = self.cl if kwargs.get("discretize", False): clalpha = clalpha.set_discrete(0, 4, 50) - cl = cl.set_discrete( - (-np.pi / 6, 0), (np.pi / 6, 2), (10, 10), mutate_self=False - ) data["cp"] = self.cp data["clalpha"] = clalpha - data["cl"] = cl return data diff --git a/rocketpy/rocket/aero_surface/tail.py b/rocketpy/rocket/aero_surface/tail.py index 0066bcf86..7ccd2e1e3 100644 --- a/rocketpy/rocket/aero_surface/tail.py +++ b/rocketpy/rocket/aero_surface/tail.py @@ -41,10 +41,12 @@ class Tail(_BarrowmanSurface): Tail.cp : tuple Tuple containing the coordinates of the center of pressure of the tail. Tail.cl : Function - Function that returns the lift coefficient of the tail. The function - is defined as a function of the angle of attack and the mach number. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. Tail.clalpha : float - Lift coefficient slope. Has the unit of 1/rad. + Normal-force coefficient slope. Has the unit of 1/rad. Tail.slant_length : float Slant length of the tail. The slant length is defined as the distance between the top and bottom of the tail. The slant length is measured @@ -184,12 +186,7 @@ def evaluate_lift_coefficient(self): ) ), "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: self.clalpha(mach) * alpha, - ["Alpha (rad)", "Mach"], - "Cl", + f"Normal-force coefficient derivative for {self.name}", ) def evaluate_center_of_pressure(self): @@ -230,18 +227,13 @@ def to_dict(self, **kwargs): if kwargs.get("include_outputs", False): clalpha = self.clalpha - cl = self.cl if kwargs.get("discretize", False): clalpha = clalpha.set_discrete(0, 4, 50) - cl = cl.set_discrete( - (-np.pi / 6, 0), (np.pi / 6, 2), (10, 10), mutate_self=False - ) data.update( { "cp": self.cp, "clalpha": clalpha, - "cl": cl, "slant_length": self.slant_length, "surface_area": self.surface_area, } diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 48ad2fa85..821eb884d 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -35,6 +35,22 @@ ) +def _stability_slope(derivative_coefficient, sign=1.0): + """Turn a surface's coefficient derivative (``cN_alpha``, ``cY_beta``, ...) + into the force-curve slope as a Function of Mach, evaluated at zero + alpha/beta and zero rates. ``sign`` flips it when needed (the yaw plane uses + ``-cY_beta``). + """ + return Function( + lambda mach: ( + sign + * derivative_coefficient.get_value_opt(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0) + ), + "Mach", + "Force coefficient slope", + ) + + # pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-instance-attributes class Rocket: """Keeps rocket information. @@ -388,10 +404,7 @@ def __init__( # pylint: disable=too-many-statements outputs="Stability Margin - Yaw (c)", ) - # Define aerodynamic drag coefficients used during flight simulation. - # Drag is stored at its intrinsic dimensionality over the seven base - # independent variables; 1-D inputs are taken as Mach, and "constant" - # extrapolation is used (drag should not extrapolate beyond its range). + # Define aerodynamic drag coefficients used during flight simulation self.power_off_drag_7d = AeroCoefficient( power_off_drag, name="Drag Coefficient with Power Off", @@ -444,14 +457,10 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - # The aerodynamic center and the margins are evaluated lazily (see the - # ``aerodynamic_center`` / ``static_margin`` properties); just flag them - # outdated here. They are rebuilt on first access, once all surfaces and - # the motor have been added. + # The aerodynamic center and the margins are evaluated lazily self._cp_outdated = True self._margin_outdated = True - # One-shot guard for the non-axisymmetric advisory (see - # ``evaluate_center_of_pressure``); warned at most once per rocket. + # Flag for rocket non-axisymmetric warning. Used to show warning once. self._axisymmetry_warned = False # Initialize plots and prints object @@ -647,16 +656,6 @@ def evaluate_thrust_to_weight(self): self.thrust_to_weight.set_title("Thrust to Weight ratio") # Lazily-evaluated aerodynamic outputs. - # - # The pitch/yaw aerodynamic centers and the static/stability margins are - # *derived* from the aerodynamic surfaces (and, for the margins, the center - # of mass). Rather than recompute them eagerly on every ``add_*`` call - an - # O(N^2) cost while building, repeated for every rocket in a Monte Carlo run - - # the mutating methods only flag them outdated; the value is rebuilt on first - # access and cached until the next change. ``_cp_outdated`` tracks the - # surface-dependent centers; ``_margin_outdated`` additionally tracks the - # center of mass, so adding a motor refreshes the margins without recomputing - # the surface-only aerodynamic center. def _ensure_aerodynamic_center(self): """Recompute the pitch/yaw aerodynamic centers if a surface changed.""" @@ -721,25 +720,21 @@ def stability_margin_yaw(self): return self._stability_margin_yaw def evaluate_center_of_pressure(self): - """Evaluates the rocket's **aerodynamic center** as a function of Mach - number, relative to the user-defined rocket reference system. + """Evaluates the rocket's aerodynamic center (and cp_position) as a + function of Mach number, relative to the user-defined rocket reference + system. - The aerodynamic center is the linearized (small-incidence, - :math:`\\alpha=\\beta=0`) center of pressure: the normal-force-slope- - weighted average of every aerodynamic surface's location. It is the - well-conditioned reference used by the static and stability margins. The - nonlinear center of pressure at a finite angle of attack/sideslip is a - separate, singular quantity (``x_cdm + csys * d * Cm / CN``) that can be - reconstructed from :meth:`aerodynamic_coefficients_full` when needed. + The aerodynamic center is the linearized (small-incidence, alpha=beta=0) + center of pressure: the normal-force-slope-weighted average of every + aerodynamic surface's location. It is computed independently for the **pitch** plane (``aerodynamic_center``, from the normal-force/pitch-moment slopes) and the **yaw** plane (``aerodynamic_center_yaw``, from the side-force/yaw-moment slopes). For an axisymmetric rocket the two - coincide; when they differ (a non-axisymmetric configuration, only - expressible through ``GenericSurface``), a warning is raised because the - scalar ``static_margin``/``stability_margin`` attributes describe the - pitch plane only. + coincide. When they differ (a non-axisymmetric configuration), a warning + is raised because the scalar ``static_margin``/``stability_margin`` + attributes describe the pitch plane only. Returns ------- @@ -764,8 +759,15 @@ def evaluate_center_of_pressure(self): # Calculate total force coefficient derivatives and aerodynamic center if len(self.aerodynamic_surfaces) > 0: for aero_surface, position in self.aerodynamic_surfaces: - lift_coeff_der = aero_surface.lift_coefficient_derivative + # Force-curve slopes as Functions of Mach, from the surface's + # coefficient derivatives evaluated at zero alpha/beta and zero + # rates. The yaw slope is the sign-flipped ``cY_beta`` so an + # axisymmetric surface gives the same signed weight as the pitch + # plane (their margins then coincide when symmetric). + lift_coeff_der = _stability_slope(aero_surface.cN_alpha) + side_coeff_der = _stability_slope(aero_surface.cY_beta, sign=-1.0) cp_z = aero_surface.center_of_pressure_z + cp_z_yaw = aero_surface.center_of_pressure_z_yaw # ref_factor corrects force for different reference areas ref_factor = aero_surface.reference_area / self.area self._total_lift_coeff_der += ref_factor * lift_coeff_der @@ -774,8 +776,6 @@ def evaluate_center_of_pressure(self): ) # Yaw plane. - side_coeff_der = aero_surface.side_coefficient_derivative - cp_z_yaw = aero_surface.center_of_pressure_z_yaw self._total_side_coeff_der += ref_factor * side_coeff_der self._aerodynamic_center_yaw += ( ref_factor * side_coeff_der * (position.z - self._csys * cp_z_yaw) @@ -786,12 +786,8 @@ def evaluate_center_of_pressure(self): if self._total_side_coeff_der.get_value(0) != 0: self._aerodynamic_center_yaw /= self._total_side_coeff_der - # One-shot non-axisymmetry advisory. Both plane centers are already built - # here, so detection costs only a Mach sweep -- no extra evaluation and no - # per-surface-add repetition. ``_cp_outdated`` was cleared at the top, so - # reading ``is_axisymmetric`` (which reads the centers) does not recurse. - # Emitted at most once per rocket, when the scalar pitch-plane margins - # first become potentially misleading. + # Non-axisymmetry advisory. Latched once per configuration: the flag is + # re-armed whenever a surface is added (see add_surfaces) if not self._axisymmetry_warned and not self.is_axisymmetric: self._axisymmetry_warned = True max_diff = self._cp_plane_max_difference() @@ -799,7 +795,7 @@ def evaluate_center_of_pressure(self): "Pitch- and yaw-plane aerodynamic centers differ " f"(max difference ~{max_diff:.4g} m): the rocket is not " "axisymmetric. 'aerodynamic_center', 'static_margin' and " - "'stability_margin' describe the PITCH plane; use " + "'stability_margin' describe the PITCH plane. Use " "'aerodynamic_center_yaw', 'static_margin_yaw' and " "'stability_margin_yaw' for the yaw plane.", stacklevel=2, @@ -809,19 +805,7 @@ def evaluate_center_of_pressure(self): def _cp_plane_max_difference(self): """Largest pitch- vs yaw-plane aerodynamic center difference, in meters. - - The difference is sampled densely across the subsonic, transonic and - supersonic regimes rather than at a few fixed Mach numbers. A - non-axisymmetric configuration (only possible through a ``GenericSurface`` - with non-mirror coefficients) can have its pitch/yaw aerodynamic centers - diverge in any Mach range, and the difference can vanish at isolated Mach - numbers; sparse fixed sampling (e.g. only 0, 0.5, 1) risks a *false - negative* -- silently reporting an asymmetric rocket as axisymmetric, so - the user trusts the pitch-plane-only static margin. The built-in - (Barrowman) surfaces are symmetric by construction, so this returns - exactly 0 for them at every Mach (no false positives). This runs once at - setup, not in the integration loop, so a dense sweep is cheap. - """ + The difference is sampled densely across the subsonic, transonic and""" # 0 to 3 in 0.2 steps covers RocketPy's flight regimes (sub/trans/ # supersonic) with enough resolution that a real asymmetry, which spans a # Mach *range*, cannot fall entirely between sample points. This only @@ -842,13 +826,9 @@ def is_axisymmetric(self): coincide (to caliber-scale tolerance). When ``False`` the rocket is not axisymmetric: ``aerodynamic_center``, ``static_margin`` and ``stability_margin`` describe the PITCH plane only and differ from their - ``*_yaw`` counterparts (``aerodynamic_center_yaw``, ``static_margin_yaw``, - ``stability_margin_yaw``).""" - # Fast path: the built-in nose, tail and fin sets contribute identically - # to the pitch and yaw planes by construction, so a rocket made only of - # surfaces that are axisymmetric-by-construction is axisymmetric without - # evaluating anything. Only a generic surface or an individual fin can - # break it, in which case fall back to the numeric Mach sweep below. + ``*_yaw`` counterparts (``aerodynamic_center_yaw``, + ``static_margin_yaw``, ``stability_margin_yaw``).""" + # Nose, tail and fin sets contribute identically to both planes. if all( getattr(surface, "is_axisymmetric", False) for surface, _ in self.aerodynamic_surfaces @@ -865,13 +845,15 @@ def cp_position(self): Mach-dependent aerodynamic center -- the slope-weighted (Barrowman) quantity the rocketry community conventionally calls the CP, and the well-conditioned reference used by the static and stability margins. - The genuine, force-application center of pressure at a finite incidence - is ``x_cdm + csys * d * Cm / CN`` and can be reconstructed on demand from - :meth:`aerodynamic_coefficients_full`; it is intentionally not exposed as - a method because it is singular at zero normal force (zero incidence). """ + # TODO: review note: I guess having the full, real nonlinear cp would be cool and good for completeness, althouhg not that useful ... should try it return self.aerodynamic_center + # TODO: review note: why are the bellow functions related to aerodynamic forces + # not using rates? What I want from this is to define a complete set of the + # entire vehicle aerodynamic coefficients. And make it as complete as possible + # than make some helper analysis functions to get something like the version + # without rates, etc. Could that be done? def _aerodynamic_forces_and_moments(self, alpha, beta, mach, reynolds=0.0): """Total body-frame aerodynamic force ``(R1, R2, R3)`` and moment ``(M1, M2, M3)`` about the center of dry mass, summed over every @@ -964,15 +946,16 @@ def aerodynamic_coefficients_full(self, alpha, beta, mach, reynolds=0.0): coefficients, referenced to the rocket cross-section area and diameter and taken about the center of dry mass, in the body aerodynamic frame: - - ``cL`` (lift), ``cQ`` (side force), ``cD`` (drag); + - ``cN`` (normal force), ``cY`` (side force), ``cA`` (axial force); - ``cm`` (pitch), ``cn`` (yaw), ``cl`` (roll). - Unlike :meth:`aerodynamic_coefficients` (which returns unsigned - normal-force and pitch-moment magnitudes) these are signed and complete. - The drag coefficient ``cD`` is taken from the vehicle drag curve - (``power_off_drag``), since the geometric surfaces carry no drag - coefficient; this unifies the per-surface lift/moment model with the - separately supplied drag curve into a single coefficient set. + These are body-frame, signed and complete, unlike + :meth:`aerodynamic_coefficients` (which returns unsigned normal-force and + pitch-moment magnitudes). The axial coefficient ``cA`` is taken from the + vehicle drag curve (``power_off_drag``), since the geometric surfaces + carry no axial coefficient; this unifies the per-surface normal-force / + moment model with the separately supplied drag curve into a single + coefficient set. Parameters ---------- @@ -986,23 +969,22 @@ def aerodynamic_coefficients_full(self, alpha, beta, mach, reynolds=0.0): Returns ------- dict - ``{"cL", "cQ", "cD", "cm", "cn", "cl"}``. + ``{"cN", "cY", "cA", "cm", "cn", "cl"}``. """ r1, r2, r3, m1, m2, m3, stream_speed = self._aerodynamic_forces_and_moments( alpha, beta, mach, reynolds ) dynamic_pressure_area = 0.5 * stream_speed**2 * self.area if dynamic_pressure_area == 0: - return {c: 0.0 for c in ("cL", "cQ", "cD", "cm", "cn", "cl")} + return {c: 0.0 for c in ("cN", "cY", "cA", "cm", "cn", "cl")} reference_length = 2 * self.radius dynamic_pressure_area_length = dynamic_pressure_area * reference_length - # Body-frame force/moment components map to the aerodynamic-frame - # coefficients (see GenericSurface.compute_forces_and_moments, which - # builds the body force from Vector([side, -lift, -drag])). + # Body-frame force components: R1 = cY, R2 = -cN, R3 = -cA (see + # GenericSurface.compute_forces_and_moments). return { - "cL": -r2 / dynamic_pressure_area, - "cQ": r1 / dynamic_pressure_area, - "cD": self.power_off_drag_by_mach.get_value_opt(mach), + "cN": -r2 / dynamic_pressure_area, + "cY": r1 / dynamic_pressure_area, + "cA": self.power_off_drag_by_mach.get_value_opt(mach), "cm": m1 / dynamic_pressure_area_length, "cn": m2 / dynamic_pressure_area_length, "cl": m3 / dynamic_pressure_area_length, @@ -1035,10 +1017,11 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): (position.z - self.center_of_dry_mass_position) * self._csys, ] ) - # position of the force application point in body frame. Surfaces that - # carry their center-of-pressure offset in the moment coefficients - # (Barrowman surfaces) apply the force at the origin; surfaces that - # transport the moment geometrically use their center of pressure. + # position of the force application point in body frame. Every surface + # applies its resultant force at its center of pressure and transports + # the moment geometrically; the surface-local application point is mapped + # into the body frame by ``_rotation_surface_to_body`` (identity for a + # generic surface, a 180-degree flip for Barrowman surfaces). application_point = getattr( surface, "force_application_point", @@ -1465,8 +1448,7 @@ def add_motor(self, motor, position): # pylint: disable=too-many-statements self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() self.evaluate_surfaces_cp_to_cdm() - # The motor changes the center of mass (and thus the margins) but not the - # surface-only aerodynamic center; flag only the margins for lazy rebuild. + # The motor changes the CM (and the margins) self._margin_outdated = True self.evaluate_com_to_cdm_function() self.evaluate_nozzle_gyration_tensor() @@ -1546,14 +1528,18 @@ def add_surfaces(self, surfaces, positions): else: self.__add_single_surface(surfaces, positions) - # Adding a surface changes both the aerodynamic center and the margins; - # flag them for lazy rebuild on next access (see the properties). The - # non-axisymmetry advisory is emitted (once) from - # ``evaluate_center_of_pressure`` on that first rebuild, rather than - # eagerly here on every add. + # Adding a surface changes both the aerodynamic center and the margins self._cp_outdated = True self._margin_outdated = True + # Re-arm the non-axisymmetry advisory: the warning is latched once per + # configuration (see evaluate_center_of_pressure), so a new surface may + # legitimately warn again about the new configuration. + self._axisymmetry_warned = False + # TODO: review note: several issues with this. First, the name is bad + # second, there should be an overwrite option, so it overwrites any existing + # surfaces on the rocket (even if a surface is added AFTER this method is called). + # TODO: review note: how can power on/power off be considered here? def add_vehicle_aerodynamic_surface( self, coefficients, reference_position=None, name="Vehicle Aerodynamics" ): diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 7c631753b..b5b8e7723 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -609,6 +609,10 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements A custom ``scipy.integrate.OdeSolver`` can be passed as well. For more information on the integration methods, see the scipy documentation [1]_. + simulation_mode : str, optional + Degrees of freedom used to integrate the trajectory. Either "6DOF" + (full translational and rotational dynamics) or "3DOF" (point-mass + translation only). Default is "6DOF". custom_events : Event or list[Event], optional Event or list of Events to be monitored during flight. See Event class for more details. Default is None. @@ -2307,19 +2311,16 @@ def static_margin(self): def stability_margin(self): """Linear stability margin along the flight, in calibers. - This is the classical (aerodynamic-center) margin: it evaluates the - rocket's linearized stability margin - (:meth:`Rocket.stability_margin`) at the realized flight Mach and time at - each instant, capturing the Mach variation of the aerodynamic center - together with the center-of-mass shift as propellant burns. It is - well-conditioned and never spikes. + This is the classical margin: it evaluates the rocket's linearized + stability margin (:meth:`Rocket.stability_margin`) at the realized + flight Mach and time at each instant, capturing the Mach variation of + the aerodynamic center together with the center-of-mass shift as + propellant burns. Returns ------- stability : rocketpy.Function - Stability margin in calibers as a function of time. A positive - margin (aerodynamic center behind the center of mass) is the classic - passive-stability condition. + Stability margin in calibers as a function of time. """ return [(t, self.rocket.stability_margin(m, t)) for t, m in self.mach_number] @@ -2330,8 +2331,8 @@ def stability_margin_yaw(self): Yaw-plane counterpart of :meth:`stability_margin`, using the rocket's yaw-plane aerodynamic center (:meth:`Rocket.stability_margin_yaw`). Equals :meth:`stability_margin` for an axisymmetric rocket; for a - non-axisymmetric rocket (e.g. single-plane canards) it differs, since the - pitch and yaw aerodynamic centers no longer coincide. + non-axisymmetric rocket (e.g. single-plane canards) it differs, since + the pitch and yaw aerodynamic centers no longer coincide. Returns ------- @@ -2343,6 +2344,8 @@ def stability_margin_yaw(self): ] # Dynamic stability + # TODO: review note: the two methods below this comment needs to have its + # equations documented in a .rst file def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): """Lateral moment of inertia about the instantaneous center of mass, as an array over ``self.time``. Uses the reduced-mass formulation of the @@ -2369,9 +2372,9 @@ def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): """Linearized oscillator coefficients for one plane, as arrays over - ``self.time``: corrective moment coefficient ``C1`` (restoring moment per - radian), damping moment coefficient ``C2`` (aerodynamic + jet), undamped - natural frequency ``omega_n`` and damping ratio ``zeta``. + ``self.time``: corrective moment coefficient ``C1`` (restoring moment + per radian), damping moment coefficient ``C2`` (aerodynamic + jet), + undamped natural frequency ``omega_n`` and damping ratio ``zeta``. ``lift_slope`` is the rocket's total normal-force-curve slope for the plane (``total_lift_coeff_der`` for pitch, ``total_side_coeff_der`` for @@ -2407,7 +2410,9 @@ def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): # Aerodynamic damping: 0.5 rho V A sum_i (A_i/A) C_Nalpha_i arm_i^2. damping_aero = 0.0 for surface, position in self.rocket.aerodynamic_surfaces: - slope = surface.lift_coefficient_derivative.get_value_opt(mach) + slope = surface.cN_alpha.get_value_opt( + 0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0 + ) cp_position = ( position.z - csys * surface.center_of_pressure_z.get_value_opt(mach) ) diff --git a/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py b/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py index 35ab9c1a2..600325391 100644 --- a/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py +++ b/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py @@ -13,7 +13,7 @@ def filename_valid_coeff_linear_generic_surface(tmpdir_factory): { "alpha": [0, 1, 2, 3, 0.1], "mach": [3, 2, 1, 0, 0.2], - "cL_0": [4, 2, 2, 4, 5], + "cN_0": [4, 2, 2, 4, 5], } ).to_csv(filename, index=False) @@ -24,7 +24,7 @@ def filename_valid_coeff_linear_generic_surface(tmpdir_factory): params=( { "alpha": [0, 1, 2, 3, 0.1], - "cL_0": [4, 2, 2, 4, 5], + "cN_0": [4, 2, 2, 4, 5], "mach": [3, 2, 1, 0, 0.2], }, { diff --git a/tests/unit/mathutils/test_function.py b/tests/unit/mathutils/test_function.py index 93c439def..e2853f1df 100644 --- a/tests/unit/mathutils/test_function.py +++ b/tests/unit/mathutils/test_function.py @@ -1505,3 +1505,90 @@ def test_regular_grid_invalid_source_raises(bad_source, match): outputs=["z"], interpolation="regular_grid", ) + + +def test_regular_grid_sorts_unsorted_axes(): + """A descending (or shuffled) axis is sorted, with the grid data reordered + to match, so the resulting Function matches the equivalent ascending grid.""" + x_axis = np.array([0.0, 1.0, 2.0]) + y_axis = np.array([0.0, 1.0, 2.0]) + x_grid, y_grid = np.meshgrid(x_axis, y_axis, indexing="ij") + data = 2.0 * x_grid + 3.0 * y_grid + + ascending = Function( + ([x_axis, y_axis], data), interpolation="regular_grid", extrapolation="natural" + ) + # First axis descending, data reversed along that axis to describe the SAME + # surface. It must be normalized to ascending and yield identical values. + descending = Function( + ([x_axis[::-1], y_axis], data[::-1, :]), + interpolation="regular_grid", + extrapolation="natural", + ) + assert np.all(np.diff(descending._grid_axes[0]) > 0) + assert np.isclose(descending(1.5, 0.5), ascending(1.5, 0.5)) + + +def test_regular_grid_repeated_axis_coordinate_raises(): + """An axis with duplicate coordinates cannot form a grid and raises a clear + error instead of a cryptic SciPy failure.""" + with pytest.raises(ValueError, match="repeated coordinates"): + Function( + ([np.array([0.0, 1.0, 1.0]), np.array([0.0, 1.0, 2.0])], np.ones((3, 3))), + interpolation="regular_grid", + ) + + +def test_from_regular_grid_csv_falls_back_when_too_few_points(tmp_path): + """A smooth grid method (e.g. cubic) needs enough points per axis; when the + grid is too coarse, from_regular_grid_csv warns and falls back to linear.""" + filename = tmp_path / "coarse_grid.csv" + # 2x2 grid: too few points for cubic (which needs 4 per axis). + filename.write_text("mach,alpha,cL\n0,0,0\n0,1,1\n1,0,1\n1,1,2\n", encoding="utf-8") + with pytest.warns(UserWarning, match="falling back to 'linear'"): + func = Function.from_regular_grid_csv( + str(filename), + ["mach", "alpha"], + "cL", + extrapolation="constant", + interpolation="cubic", + ) + assert func is not None + assert getattr(func, "_grid_method", "linear") == "linear" + + +def test_regular_grid_caches_domain_bounds(bilinear_grid_2d): + """The N-D hot path caches per-dimension domain bounds at source time.""" + assert np.allclose(bilinear_grid_2d._domain_min, [0.0, 0.0]) + assert np.allclose(bilinear_grid_2d._domain_max, [2.0, 2.0]) + + +def test_regular_grid_dict_round_trip(bilinear_grid_2d): + """A regular_grid Function round-trips through to_dict/from_dict, rebuilding + from the (axes, grid_data) structure rather than the flat scatter source.""" + restored = Function.from_dict(bilinear_grid_2d.to_dict()) + + assert restored.get_interpolation_method() == "regular_grid" + assert restored.get_domain_dim() == 2 + for x, y in [(0.5, 1.5), (1.25, 0.75), (2.0, 2.0)]: + assert np.isclose(restored(x, y), bilinear_grid_2d(x, y)) + + +def test_regular_grid_dict_round_trip_preserves_method(tmp_path): + """A non-default grid method (e.g. pchip) survives to_dict/from_dict.""" + filename = tmp_path / "grid.csv" + rows = ["x,y,z"] + for x in (0, 1, 2, 3): + for y in (0, 1, 2, 3): + rows.append(f"{x},{y},{x + 10 * y**2}") + filename.write_text("\n".join(rows) + "\n", encoding="utf-8") + + original = Function.from_regular_grid_csv( + str(filename), ["x", "y"], "z", extrapolation="constant", interpolation="akima" + ) + assert original._grid_method == "pchip" + + restored = Function.from_dict(original.to_dict()) + assert restored.get_interpolation_method() == "regular_grid" + assert getattr(restored, "_grid_method", "linear") == "pchip" + assert np.isclose(restored(0.5, 1.5), original(0.5, 1.5)) diff --git a/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py index f9828d205..ebc00c72a 100644 --- a/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py +++ b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py @@ -1,10 +1,11 @@ """Regression tests for the GenericSurface-rooted aerodynamic hierarchy. -After the refactor, every aerodynamic surface (Barrowman or generic) is -described by the generic coefficient model and exposes the diagnostic accessors -``lift_coefficient_derivative`` and ``center_of_pressure_z`` used by the rocket's -center-of-pressure / stability-margin computation. These tests pin the -properties that the refactor is meant to guarantee. +After the refactor, every aerodynamic surface (Barrowman or generic) exposes +the coefficient derivatives ``cN_alpha``/``cY_beta`` and the +``center_of_pressure_z`` accessor used by the rocket's center-of-pressure / +stability-margin computation. (Barrowman surfaces still compute their flight +forces with the classic geometric method; the derivatives feed only the +stability diagnostics.) These tests pin the properties the refactor guarantees. """ import warnings @@ -16,9 +17,8 @@ def test_barrowman_derived_cp_matches_geometric_cp(): - """The derived ``center_of_pressure_z`` must reproduce the geometric cp of - each Barrowman surface (the moment is carried by ``cm`` but the diagnostic - must recover the original location).""" + """The derived ``center_of_pressure_z`` diagnostic must reproduce the + geometric cp of each Barrowman surface.""" nose = NoseCone( length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 ) @@ -37,9 +37,9 @@ def test_barrowman_derived_cp_matches_geometric_cp(): ) == surface.cpz ) - # The normal-force slope diagnostic must equal the Barrowman clalpha. + # The normal-force slope derivative must equal the Barrowman clalpha. assert pytest.approx( - nose.lift_coefficient_derivative.get_value_opt(0.0) + nose.cN_alpha.get_value_opt(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) ) == nose.clalpha.get_value_opt(0.0) @@ -56,7 +56,7 @@ def test_generic_surface_contributes_to_static_margin(calisto_motorless): reference_area=rocket.area, reference_length=2 * rocket.radius, coefficients={ - "cL_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cN_alpha": lambda a, b, m, re, p, q, r: 2.0, "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, }, name="generic_fins", @@ -78,7 +78,7 @@ def test_zero_lift_surface_does_not_break_cp(calisto_motorless): drag_only = LinearGenericSurface( reference_area=rocket.area, reference_length=2 * rocket.radius, - coefficients={"cD_0": lambda a, b, m, re, p, q, r: 0.5}, + coefficients={"cA_0": lambda a, b, m, re, p, q, r: 0.5}, name="drag_only", ) rocket.add_surfaces(drag_only, positions=-1.0) @@ -123,9 +123,9 @@ def test_non_axisymmetric_rocket_splits_margins_and_warns(calisto_motorless): reference_area=rocket.area, reference_length=2 * rocket.radius, coefficients={ - "cL_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cN_alpha": lambda a, b, m, re, p, q, r: 2.0, "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, - "cQ_beta": lambda a, b, m, re, p, q, r: -2.0, + "cY_beta": lambda a, b, m, re, p, q, r: -2.0, "cn_beta": lambda a, b, m, re, p, q, r: 2.0, }, name="asym", @@ -137,27 +137,28 @@ def test_non_axisymmetric_rocket_splits_margins_and_warns(calisto_motorless): with pytest.warns(UserWarning, match="not\\s+axisymmetric"): ac_pitch = rocket.aerodynamic_center.get_value_opt(0.2) - assert ac_pitch != pytest.approx( - rocket.aerodynamic_center_yaw.get_value_opt(0.2) - ) + assert ac_pitch != pytest.approx(rocket.aerodynamic_center_yaw.get_value_opt(0.2)) assert rocket.static_margin.get_value_opt(0) != pytest.approx( rocket.static_margin_yaw.get_value_opt(0) ) -def test_barrowman_surface_uses_generic_compute_path(): - """Barrowman surfaces must route through the shared generic - ``compute_forces_and_moments`` (no bespoke override) and apply their force - at the origin (moment carried by the coefficients).""" +def test_barrowman_surface_uses_geometric_compute_path(): + """Barrowman surfaces compute their normal force and moment with the classic + Barrowman method (their own ``compute_forces_and_moments``): the resultant + force is reported at the geometric center of pressure and its moment is + transported geometrically from there.""" + from rocketpy.rocket.aero_surface._barrowman_surface import _BarrowmanSurface from rocketpy.rocket.aero_surface.generic_surface import GenericSurface nose = NoseCone( length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 ) assert isinstance(nose, GenericSurface) - # Force is applied at the origin; the cp offset lives in cm/cn. - assert tuple(nose.force_application_point) == (0, 0, 0) + # Force is reported at the surface's geometric center of pressure. + assert tuple(nose.force_application_point) == (nose.cpx, nose.cpy, nose.cpz) + # Uses the Barrowman geometric compute, not the generic coefficient path. assert ( nose.compute_forces_and_moments.__func__ - is GenericSurface.compute_forces_and_moments + is _BarrowmanSurface.compute_forces_and_moments ) diff --git a/tests/unit/rocket/aero_surface/test_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_generic_surfaces.py index 43543a50e..86e2269a6 100644 --- a/tests/unit/rocket/aero_surface/test_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_generic_surfaces.py @@ -10,12 +10,12 @@ @pytest.mark.parametrize( "coefficients", [ - "cL", + "cN", {"invalid_name": 0}, - {"cL": "inexistent_file.csv"}, - {"cL": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, - {"cL": lambda x1: 0}, - {"cL": {}}, + {"cN": "inexistent_file.csv"}, + {"cN": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, + {"cN": lambda x1: 0}, + {"cN": {}}, ], ) def test_invalid_initialization(coefficients): @@ -37,7 +37,7 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff): GenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL": str(filename_invalid_coeff)}, + coefficients={"cN": str(filename_invalid_coeff)}, ) @@ -45,11 +45,11 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff): "coefficients", [ {}, - {"cL": 0}, + {"cN": 0}, { - "cL": 0, - "cQ": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), - "cD": lambda x1, x2, x3, x4, x5, x6, x7: 0, + "cN": 0, + "cY": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), + "cA": lambda x1, x2, x3, x4, x5, x6, x7: 0, }, ], ) @@ -70,7 +70,7 @@ def test_valid_initialization_from_csv(filename_valid_coeff): GenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL": str(filename_valid_coeff)}, + coefficients={"cN": str(filename_valid_coeff)}, ) @@ -79,25 +79,146 @@ def test_csv_independent_variables_accept_any_order(tmp_path): regardless of independent variable column order.""" filename = tmp_path / "valid_coefficients_shuffled_order.csv" filename.write_text( - "mach,alpha,cL\n0,0,0\n0,1,10\n2,0,2\n2,1,12\n", + "mach,alpha,cN\n0,0,0\n0,1,10\n2,0,2\n2,1,12\n", encoding="utf-8", ) generic_surface = GenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL": str(filename)}, + coefficients={"cN": str(filename)}, ) # The coefficient is stored at minimal dimension over its CSV columns, in # header order; AeroCoefficient maps the full argument tuple onto them. - assert generic_surface.cL.depends_on == ("mach", "alpha") - csv_function = generic_surface.cL.function + assert generic_surface.cN.depends_on == ("mach", "alpha") + csv_function = generic_surface.cN.function - assert generic_surface.cL(1, 0, 2, 0, 0, 0, 0) == pytest.approx(12) + assert generic_surface.cN(1, 0, 2, 0, 0, 0, 0) == pytest.approx(12) assert csv_function.get_interpolation_method() == "regular_grid" +POINTS = [[0, 0], [1, 1], [2, 4], [3, 9]] + + +def test_interpolation_extrapolation_scalar_applies_to_all(): + """A single interpolation/extrapolation string is applied to every + tabulated coefficient.""" + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": POINTS, "cA": POINTS}, + extrapolation="constant", + interpolation="akima", + ) + for coeff in (gs.cN, gs.cA): + assert coeff.function.get_interpolation_method() == "akima" + assert coeff.function.get_extrapolation_method() == "constant" + + +def test_interpolation_extrapolation_per_coefficient_dict(): + """A dict configures interpolation/extrapolation per coefficient; omitted + coefficients keep the default.""" + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": POINTS, "cA": POINTS}, + extrapolation={"cA": "constant"}, + interpolation={"cN": "akima"}, + ) + assert gs.cN.function.get_interpolation_method() == "akima" + assert gs.cA.function.get_extrapolation_method() == "constant" + # cN was not in the extrapolation dict, so it keeps the tabulated default. + assert gs.cN.function.get_interpolation_method() == "akima" + assert gs.cA.function.get_interpolation_method() == "linear" + + +def test_prebuilt_function_interpolation_left_unchanged(): + """A pre-built Function keeps its own interpolation/extrapolation when none + is requested, and is copied (not mutated) when they are overridden.""" + source = Function(POINTS, interpolation="spline", extrapolation="zero") + + unchanged = GenericSurface(REFERENCE_AREA, REFERENCE_LENGTH, {"cN": source}) + assert unchanged.cN.function.get_interpolation_method() == "spline" + assert unchanged.cN.function.get_extrapolation_method() == "zero" + + overridden = GenericSurface( + REFERENCE_AREA, + REFERENCE_LENGTH, + {"cN": source}, + interpolation="linear", + extrapolation="constant", + ) + assert overridden.cN.function.get_interpolation_method() == "linear" + # The original Function must not have been mutated in place. + assert source.get_interpolation_method() == "spline" + + +def test_tabulated_coefficient_defaults_to_constant_extrapolation(): + """Tabulated coefficients default to constant extrapolation, so they do not + run to non-physical values past their data.""" + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": POINTS}, + ) + assert gs.cN.function.get_extrapolation_method() == "constant" + + +def _write_grid_csv(path): + """A 4x4 (mach, alpha) Cartesian grid, nonlinear in alpha so interpolation + methods produce distinguishable values. 4 points per axis lets "cubic" fit. + """ + rows = ["mach,alpha,cN"] + for mach in (0, 1, 2, 3): + for alpha in (0.0, 0.1, 0.2, 0.3): + rows.append(f"{mach},{alpha},{mach + 10 * alpha**2}") + path.write_text("\n".join(rows) + "\n", encoding="utf-8") + return str(path) + + +@pytest.mark.parametrize( + "interpolation, expected_grid_method", + [("linear", "linear"), ("spline", "cubic"), ("akima", "pchip")], +) +def test_grid_csv_interpolation_maps_to_scipy_method( + tmp_path, interpolation, expected_grid_method +): + """A gridded CSV honors the interpolation argument by mapping it onto the + RegularGridInterpolator method (no silent fallback to shepard).""" + filename = _write_grid_csv(tmp_path / "grid.csv") + + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": filename}, + interpolation=interpolation, + ) + function = gs.cN.function + # The Function stays a regular grid (not clobbered to shepard) ... + assert function.get_interpolation_method() == "regular_grid" + # ... with the mapped scipy method threaded through. + assert getattr(function, "_grid_method", "linear") == expected_grid_method + + +def test_grid_csv_cubic_differs_from_linear(tmp_path): + """The mapped grid method actually changes interpolation off the grid nodes, + confirming it is not ignored.""" + filename = _write_grid_csv(tmp_path / "grid.csv") + + linear = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": filename}, interpolation="linear" + ) + cubic = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": filename}, interpolation="spline" + ) + # Interior off-node point at alpha=0.15, mach=0.5 (argument order is + # alpha, beta, mach, ...): the nonlinear-in-alpha grid makes cubic and + # linear disagree there. + args = (0.15, 0.0, 0.5, 0, 0, 0, 0) + assert linear.cN(*args) != pytest.approx(cubic.cN(*args)) + + def test_compute_forces_and_moments(): """Checks if there are not logical errors in compute forces and moments""" diff --git a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py index 88d7973cb..8f3095fd9 100644 --- a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py @@ -10,12 +10,12 @@ @pytest.mark.parametrize( "coefficients", [ - "cL_0", + "cN_0", {"invalid_name": 0}, - {"cL_0": "inexistent_file.csv"}, - {"cL_0": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, - {"cL_0": lambda x1: 0}, - {"cL_0": {}}, + {"cN_0": "inexistent_file.csv"}, + {"cN_0": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, + {"cN_0": lambda x1: 0}, + {"cN_0": {}}, ], ) def test_invalid_initialization(coefficients): @@ -37,7 +37,7 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff_linear_generic_s LinearGenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL_0": str(filename_invalid_coeff_linear_generic_surface)}, + coefficients={"cN_0": str(filename_invalid_coeff_linear_generic_surface)}, ) @@ -45,11 +45,11 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff_linear_generic_s "coefficients", [ {}, - {"cL_0": 0}, + {"cN_0": 0}, { - "cL_0": 0, - "cQ_0": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), - "cD_0": lambda x1, x2, x3, x4, x5, x6, x7: 0, + "cN_0": 0, + "cY_0": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), + "cA_0": lambda x1, x2, x3, x4, x5, x6, x7: 0, }, ], ) @@ -70,7 +70,7 @@ def test_valid_initialization_from_csv(filename_valid_coeff_linear_generic_surfa LinearGenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL_0": str(filename_valid_coeff_linear_generic_surface)}, + coefficients={"cN_0": str(filename_valid_coeff_linear_generic_surface)}, ) diff --git a/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py b/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py new file mode 100644 index 000000000..674e583b7 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py @@ -0,0 +1,206 @@ +"""Guard tests ensuring every concrete aerodynamic surface exposes the full +coefficient contract the rocket and flight code rely on. + +Each surface must provide the six force/moment coefficients (``cL``, ``cQ``, +``cD``, ``cm``, ``cn``, ``cl``) and the four stability derivatives +(``cN_alpha``, ``cY_beta``, ``cm_alpha``, ``cn_beta``) as callables over its +independent-variable tuple, plus the pitch and yaw center-of-pressure accessors. +These tests catch a subclass silently omitting one. +""" + +import numpy as np +import pytest + +from rocketpy import ( + AirBrakes, + ControllableGenericSurface, + EllipticalFin, + EllipticalFins, + FreeFormFin, + FreeFormFins, + GenericSurface, + LinearGenericSurface, + NoseCone, + Tail, + TrapezoidalFin, + TrapezoidalFins, +) + +R = 0.0635 # a representative rocket radius, in meters +_SHAPE = [(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)] + + +def _make_surfaces(): + """One instance of every concrete aerodynamic surface class.""" + area, length = np.pi * R**2, 2 * R + return { + "GenericSurface": GenericSurface( + reference_area=area, reference_length=length, coefficients={"cL": 1.0} + ), + "LinearGenericSurface": LinearGenericSurface( + reference_area=area, + reference_length=length, + coefficients={"cN_alpha": 2.0}, + ), + "ControllableGenericSurface": ControllableGenericSurface( + reference_area=area, + reference_length=length, + coefficients={"cL": lambda a, b, m, re, p, q, r, d: 1.0}, + ), + "AirBrakes": AirBrakes( + drag_coefficient_curve=lambda deployment, mach: 0.5, + reference_area=area, + ), + "NoseCone": NoseCone( + length=0.55829, kind="vonkarman", base_radius=R, rocket_radius=R + ), + "Tail": Tail(top_radius=R, bottom_radius=0.0435, length=0.06, rocket_radius=R), + "TrapezoidalFins": TrapezoidalFins( + n=4, span=0.1, root_chord=0.12, tip_chord=0.04, rocket_radius=R + ), + "EllipticalFins": EllipticalFins( + n=4, span=0.1, root_chord=0.12, rocket_radius=R + ), + "FreeFormFins": FreeFormFins(n=4, shape_points=_SHAPE, rocket_radius=R), + "TrapezoidalFin": TrapezoidalFin( + angular_position=0, + span=0.1, + root_chord=0.12, + tip_chord=0.04, + rocket_radius=R, + ), + "EllipticalFin": EllipticalFin( + angular_position=0, span=0.1, root_chord=0.12, rocket_radius=R + ), + "FreeFormFin": FreeFormFin( + angular_position=0, shape_points=_SHAPE, rocket_radius=R + ), + } + + +SURFACES = _make_surfaces() + +# All nine force/moment coefficients: the wind-frame forces (lift cL, side cQ, +# drag cD), the body-frame forces (normal cN, side cY, axial cA), and the moments +# (pitch cm, yaw cn, roll cl). The body-frame trio is derived from the wind trio +# (or vice versa) by the angle-of-attack/sideslip rotation. Note the +# case-sensitive distinction between ``cL`` (lift) and ``cl`` (roll). +FORCE_MOMENT_COEFFICIENTS = ( + "cL", + "cQ", + "cD", + "cN", + "cY", + "cA", + "cm", + "cn", + "cl", +) +STABILITY_DERIVATIVES = ("cN_alpha", "cY_beta", "cm_alpha", "cn_beta") + + +def _surface_params(): + return [pytest.param(name, id=name) for name in SURFACES] + + +def _coefficient_arguments(surface): + """A representative independent-variable tuple for the surface: the seven + base variables (alpha, beta, mach, reynolds, and the three rates) plus any + unsteady / control axes the surface adds, filled with zeros.""" + base = [0.05, 0.02, 0.5, 1e6, 0.0, 0.0, 0.0] + extra = len(surface.independent_vars) - len(base) + return tuple(base + [0.0] * max(0, extra)) + + +@pytest.mark.parametrize("name", _surface_params()) +@pytest.mark.parametrize("coefficient", FORCE_MOMENT_COEFFICIENTS) +def test_force_moment_coefficient_is_callable(name, coefficient): + """Every surface exposes all nine force/moment coefficients (wind cL/cQ/cD, + body cN/cY/cA, moments cm/cn/cl) as coefficients callable over the + independent-variable tuple that return a finite value.""" + surface = SURFACES[name] + coeff = getattr(surface, coefficient, None) + assert coeff is not None, f"{name} is missing coefficient {coefficient}" + value = coeff.get_value_opt(*_coefficient_arguments(surface)) + assert np.isfinite(value), f"{name}.{coefficient} returned {value}" + + +@pytest.mark.parametrize("name", _surface_params()) +@pytest.mark.parametrize("derivative", STABILITY_DERIVATIVES) +def test_stability_derivative_is_callable(name, derivative): + """Every surface exposes the stability derivatives cN_alpha, cY_beta, + cm_alpha and cn_beta used by the rocket's center-of-pressure computation.""" + surface = SURFACES[name] + coeff = getattr(surface, derivative, None) + assert coeff is not None, f"{name} is missing derivative {derivative}" + value = coeff.get_value_opt(*_coefficient_arguments(surface)) + assert np.isfinite(value), f"{name}.{derivative} returned {value}" + + +@pytest.mark.parametrize("name", _surface_params()) +def test_center_of_pressure_accessors(name): + """Every surface exposes the pitch and yaw center-of-pressure accessors used + by the rocket's aerodynamic-center computation.""" + surface = SURFACES[name] + for attr in ("center_of_pressure_z", "center_of_pressure_z_yaw"): + accessor = getattr(surface, attr, None) + assert accessor is not None, f"{name} is missing {attr}" + assert np.isfinite(accessor.get_value_opt(0.5)) + + +_ARGS = (0.15, 0.08, 0.5, 1e6, 0.0, 0.0, 0.0) + + +def test_body_input_is_recovered_by_body_accessors(): + """Coefficients supplied in the body frame are recovered by the body-frame + accessors (they round-trip through the canonical wind-frame storage).""" + from rocketpy import GenericSurface + + surface = GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={ + "cN": lambda a, b, m, re, p, q, r: 2.0 * a, + "cA": lambda a, b, m, re, p, q, r: 0.5, + "cY": lambda a, b, m, re, p, q, r: 1.5 * b, + }, + ) + assert surface.force_convention == "body" + assert surface.cN.get_value_opt(*_ARGS) == pytest.approx(2.0 * _ARGS[0]) + assert surface.cA.get_value_opt(*_ARGS) == pytest.approx(0.5) + assert surface.cY.get_value_opt(*_ARGS) == pytest.approx(1.5 * _ARGS[1]) + + +def test_wind_and_body_input_agree_at_zero_angle(): + """cL == cN, cD == cA and cQ == cY at zero angle of attack and sideslip, + regardless of the frame the coefficients were supplied in.""" + from rocketpy import GenericSurface + + wind = GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={"cL": lambda a, b, m, re, p, q, r: 2.0}, + force_convention="wind", + ) + body = GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={"cN": lambda a, b, m, re, p, q, r: 2.0}, + force_convention="body", + ) + zero = (0.0, 0.0, 0.5, 1e6, 0.0, 0.0, 0.0) + assert wind.cN.get_value_opt(*zero) == pytest.approx(2.0) + assert body.cL.get_value_opt(*zero) == pytest.approx(2.0) + + +def test_mixed_frame_input_raises(): + """Supplying both wind and body force coefficients without declaring the + frame is rejected.""" + from rocketpy import GenericSurface + + with pytest.raises(ValueError, match="[Mm]ixed"): + GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={"cL": 1.0, "cN": 1.0}, + ) diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 623eebf1a..02fbed668 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -142,7 +142,7 @@ def test_add_trapezoidal_fins_sweep_angle( assert translate - cpz == pytest.approx(expected_fin_cpz, 0.01) # Check lift coefficient derivative - cl_alpha = fin_set.cl(1, 0.0) + cl_alpha = fin_set.clalpha(0.0) assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) @@ -184,7 +184,7 @@ def test_add_trapezoidal_fins_sweep_length( assert translate - cpz == pytest.approx(expected_fin_cpz, 0.01) # Check lift coefficient derivative - cl_alpha = fin_set.cl(1, 0.0) + cl_alpha = fin_set.clalpha(0.0) assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) diff --git a/tests/unit/rocket/test_stability_rework.py b/tests/unit/rocket/test_stability_rework.py index e911b9feb..b2995a514 100644 --- a/tests/unit/rocket/test_stability_rework.py +++ b/tests/unit/rocket/test_stability_rework.py @@ -30,7 +30,7 @@ def test_reconstructed_center_of_pressure_converges_to_aerodynamic_center( cdm = rocket.center_of_dry_mass_position coeffs = rocket.aerodynamic_coefficients_full(np.radians(0.1), 0.0, mach) - reconstructed_cp = cdm + csys * diameter * coeffs["cm"] / coeffs["cL"] + reconstructed_cp = cdm + csys * diameter * coeffs["cm"] / coeffs["cN"] assert reconstructed_cp == pytest.approx(aerodynamic_center, abs=1e-3) @@ -57,17 +57,18 @@ def test_axisymmetric_rocket_planes_coincide(calisto_robust): def test_aerodynamic_coefficients_full_signed_set(calisto_robust): - """The full rocket coefficient set returns all six signed coefficients; - lift grows with alpha, drag comes from the vehicle drag curve, and the pitch - moment is restoring (negative) for a stable rocket.""" + """The full rocket coefficient set returns all six signed body-frame + coefficients; normal force grows with alpha, axial force comes from the + vehicle drag curve, and the pitch moment is restoring (negative) for a + stable rocket.""" rocket = calisto_robust coeffs = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3) - assert set(coeffs) == {"cL", "cQ", "cD", "cm", "cn", "cl"} + assert set(coeffs) == {"cN", "cY", "cA", "cm", "cn", "cl"} low = rocket.aerodynamic_coefficients_full(np.radians(2), 0.0, 0.3) - assert coeffs["cL"] > low["cL"] > 0 + assert coeffs["cN"] > low["cN"] > 0 assert coeffs["cm"] < 0 # restoring pitch moment about the center of dry mass - assert coeffs["cD"] == pytest.approx( + assert coeffs["cA"] == pytest.approx( rocket.power_off_drag_by_mach.get_value_opt(0.3) ) @@ -76,18 +77,18 @@ def test_add_vehicle_aerodynamic_surface(calisto_robust): """A supplied full-vehicle coefficient set is added as a single generic surface and contributes to the rocket aggregate (rocket-as-GenericSurface).""" rocket = calisto_robust - base_cl = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cL"] + base_cn = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cN"] n_before = len(rocket.aerodynamic_surfaces) surface = rocket.add_vehicle_aerodynamic_surface( - coefficients={"cL": lambda a, b, m, re, p, q, r: 2.0 * a} + coefficients={"cN": lambda a, b, m, re, p, q, r: 2.0 * a} ) assert len(rocket.aerodynamic_surfaces) == n_before + 1 # The vehicle surface exposes the uniform coefficient accessors. - assert surface.cL(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( + assert surface.cN(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( 2.0 * np.radians(5) ) - # Its lift adds to the rocket aggregate. - new_cl = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cL"] - assert new_cl > base_cl + # Its normal force adds to the rocket aggregate. + new_cn = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cN"] + assert new_cn > base_cn diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py index 0d42ee3a3..eacd2f3e1 100644 --- a/tests/unit/simulation/test_flight.py +++ b/tests/unit/simulation/test_flight.py @@ -240,8 +240,8 @@ def test_export_sensor_data(flight_calisto_with_sensors): @pytest.mark.parametrize( "flight_time, expected_values", [ - ("t_initial", (-0.256474, -0.221748, 0)), - ("out_of_rail_time", (0.780787, -1.967135, 0)), + ("t_initial", (0.25886, -0.649623, 0)), + ("out_of_rail_time", (0.792028, -1.987634, 0)), ("apogee_time", (-0.509420, -0.732933, -2.089120e-14)), ("t_final", (0, 0, 0)), ], @@ -279,9 +279,9 @@ def test_aerodynamic_moments(flight_calisto_custom_wind, flight_time, expected_v @pytest.mark.parametrize( "flight_time, expected_values", [ - ("t_initial", (-0.062135, -1.936030, 1.612160)), - ("out_of_rail_time", (4.968766, 1.957238, -0.629070)), - ("apogee_time", (2.343357, -1.606424, -0.377026)), + ("t_initial", (1.654150, 0.659142, -0.067103)), + ("out_of_rail_time", (5.052628, 2.013361, -1.75370)), + ("apogee_time", (2.321838, -1.613641, -0.962108)), ("t_final", (-0.019802, 0.012030, 159.051604)), ], ) From f6dd040970dcfd3e7c33da480aae712373df5c23 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 10 Jul 2026 19:19:01 -0300 Subject: [PATCH 6/8] DOC: improve generic_surfaces rst --- docs/user/rocket/generic_surface.rst | 187 ++++++++++++++++++++++++++- requirements.txt | 2 +- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/docs/user/rocket/generic_surface.rst b/docs/user/rocket/generic_surface.rst index bf9bdcfd0..70cbd4d36 100644 --- a/docs/user/rocket/generic_surface.rst +++ b/docs/user/rocket/generic_surface.rst @@ -111,6 +111,72 @@ Where: Commonly the rocket's diameter is used as the reference length. +Wind-frame and body-frame force coefficients +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The three force coefficients above are given in the **aerodynamic (wind) frame**, +relative to the velocity vector: + +- :math:`C_L` (lift), :math:`C_Q` (side force) and :math:`C_D` (drag). + +The same force can be expressed in the **body frame**, relative to the rocket's +axes, which is what tools such as Missile DATCOM, wind tunnels and Barrowman +report: + +- :math:`C_N` (normal force, perpendicular to the body axis), +- :math:`C_Y` (body side force), +- :math:`C_A` (axial force, along the body axis). + +The two sets are the same force in different frames, related by the +angle-of-attack/sideslip rotation :math:`\mathbf{M}_{BA}`: + +.. math:: + \begin{aligned} + C_N &= \cos\alpha\, C_L + \sin\alpha\,(\sin\beta\, C_Q + \cos\beta\, C_D) \\ + C_Y &= \cos\beta\, C_Q - \sin\beta\, C_D \\ + C_A &= -\sin\alpha\, C_L + \cos\alpha\,(\sin\beta\, C_Q + \cos\beta\, C_D) + \end{aligned} + +At small angles these reduce to :math:`C_N \approx C_L`, :math:`C_Y \approx C_Q` +and :math:`C_A \approx C_D`. + +Every aerodynamic surface exposes **all nine** coefficients as attributes +(``cL``, ``cQ``, ``cD``, ``cN``, ``cY``, ``cA``, ``cm``, ``cn``, ``cl``). The +coefficients you did not provide are computed on demand from the ones you did, +so you can always read a surface's forces in whichever frame you need, for +example ``surface.cN`` for the normal-force coefficient. + +**Choosing the input frame.** Because rocket aerodynamic data (DATCOM, wind +tunnel, CFD, Barrowman) is usually reported in the body frame, you can supply +your coefficients in either frame and RocketPy converts them for you. Provide the +wind-frame names (``cL``/``cQ``/``cD``) or the body-frame names +(``cN``/``cY``/``cA``); the moment coefficients (``cm``/``cn``/``cl``) are the +same in both. + +Moment reference point +~~~~~~~~~~~~~~~~~~~~~~~~ + +The moment coefficients :math:`C_m`, :math:`C_n` and :math:`C_l` are taken about +the surface's own reference point (its ``center_of_pressure``). When the rocket +assembles the total aerodynamic moment it transports each surface's force from +that point to the rocket's **center of dry mass**, adding the +:math:`\vec{r}_{\text{cp} \to \text{cdm}} \times \vec{F}` term, so the rocket's +reported pitch/yaw moment and static margin are about the center of dry mass. + +This matters when your coefficients come from a source that uses a different +reference. Aerodynamic decks frequently give the pitch moment **about the nose +tip** (or another fixed station) rather than about the center of dry mass. A +pitch-moment coefficient referenced to a point a distance :math:`d` ahead of the +surface's center of pressure must be shifted before use: + +.. math:: + C_{m,\,\text{cp}} = C_{m,\,\text{ref}} + \frac{d}{L_{ref}}\, C_N + +Provide the coefficient about the surface's center of pressure (or set +``center_of_pressure`` so the transport lands the moment at the intended point); +otherwise the static margin will be off by the reference-point offset. + + Aerodynamic angles ~~~~~~~~~~~~~~~~~~ @@ -263,7 +329,18 @@ independent variables: - ``yaw_rate``: Yaw rate. - ``roll_rate``: Roll rate. -The last column must be the coefficient value, and must contain a header, +When the surface is created with ``unsteady_aero=True``, the coefficients may +additionally depend on the time derivatives of the flow angles, appended after +``roll_rate``: + +- ``alpha_dot``: Rate of change of the angle of attack. +- ``beta_dot``: Rate of change of the side slip angle. + +Callables must then accept the two extra trailing arguments +(``coefficient(alpha, beta, Ma, Re, q, r, p, alpha_dot, beta_dot)``) and +``.csv`` files may include ``alpha_dot``/``beta_dot`` columns. + +The last column must be the coefficient value, and must contain a header, though the header name can be anything. .. important:: @@ -451,3 +528,111 @@ shown below: rocket.add_surfaces(linear_generic_surface, position=(0,0,0)) +.. _generic_surface_interpolation: + +Interpolation and Extrapolation of Tabulated Coefficients +--------------------------------------------------------- + +When a coefficient is provided as tabulated data (a ``.csv`` file or a list of +points), RocketPy stores it as a :class:`rocketpy.Function` and must decide two +things: how to **interpolate** *between* the tabulated points, and how to +**extrapolate** *outside* the tabulated range. Both :class:`rocketpy.GenericSurface` +and :class:`rocketpy.LinearGenericSurface` (and +:class:`rocketpy.ControllableGenericSurface`) expose these as the +``interpolation`` and ``extrapolation`` arguments. + +.. note:: + Interpolation and extrapolation only apply to **tabulated** coefficients. + A coefficient given as a constant or a callable is evaluated directly, so + these settings have no effect on it (a callable is assumed valid over its + whole domain). + +Each argument accepts either: + +- a **single string**, applied to every coefficient of the surface; or +- a **dictionary** keyed by coefficient name, setting the method per + coefficient. Coefficients omitted from the dictionary keep the default. + +.. code-block:: python + + from rocketpy import GenericSurface + + radius = 0.0635 + generic_surface = GenericSurface( + reference_area=np.pi * radius**2, + reference_length=2 * radius, + coefficients={ + "cD": "cD.csv", + "cL": "cL.csv", + }, + # A single method applied to every coefficient: + extrapolation="constant", + # ... or per coefficient (unlisted ones keep the default): + interpolation={"cD": "linear", "cL": "akima"}, + ) + +Choosing an interpolation method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Interpolation controls the behavior *between* tabulated points. For 1-D tables +the options are ``"linear"``, ``"akima"``, ``"spline"`` and ``"polynomial"``. + +- ``"linear"`` (**default**) is the safe choice. It never overshoots and + introduces no spurious oscillations, which matters most across the + **transonic drag rise** (:math:`Ma \approx 0.8`–:math:`1.2`), where a spline + will oscillate and invent non-physical wiggles in :math:`C_D`. Prefer it for + coarse tables and for anything with a sharp feature. +- ``"akima"`` gives continuous first derivatives (smoother + :math:`C_{m_\alpha}`, cleaner stability curves) while resisting the overshoot + of a natural cubic spline near kinks. It is the best "smooth" option for + **dense, smooth** data, such as lift/moment slopes in the attached-flow + region. +- ``"spline"`` produces the smoothest derivatives but overshoots near sharp + features (stall, :math:`Ma = 1`). Use it only for genuinely smooth, + well-resolved data. + +A practical rule of thumb: use ``"linear"`` against Mach (transonic kinks) and +``"akima"`` against angle of attack / sideslip when you have fine data and care +about smooth derivatives. + +.. note:: + Multi-dimensional CSV tables that form a strict Cartesian grid are read with + a :class:`scipy.interpolate.RegularGridInterpolator`. The ``interpolation`` + argument still applies: it is mapped onto the interpolator's method, with + ``"spline"`` becoming ``"cubic"`` and ``"akima"`` becoming the + shape-preserving ``"pchip"`` (``"linear"`` stays linear). Smooth methods need + enough samples per axis (``"cubic"`` needs at least 4), otherwise SciPy + raises. + +Choosing an extrapolation method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Extrapolation controls the behavior *outside* the tabulated range. The options +are ``"constant"``, ``"natural"`` and ``"zero"``. This choice matters more than +interpolation, because a bad one fails silently, precisely when the rocket is at +an extreme condition beyond your data. + +- ``"constant"`` holds the value at the nearest edge of the data. This is the + **default for tabulated coefficients**, and the right choice for essentially + all of them: a rocket can briefly exceed your tabulated Mach/angle range, and + holding the last value is bounded and physically conservative. +- ``"zero"`` returns 0 outside the range. Occasionally reasonable for force or + moment *slopes* if you want contributions to vanish past the modeled envelope, + but it introduces a discontinuity at the edge. +- ``"natural"`` continues the fitted curve past the data. **Avoid this for + tabulated coefficients**: extrapolating a linear or spline fit can send + :math:`C_D` or a moment slope to large, non-physical values right when the + rocket is at an extreme condition. + +.. tip:: + Tabulated coefficients default to ``extrapolation="constant"`` so they never + run to non-physical values past the tabulated envelope. Override it only when + you have a specific reason (e.g. ``"zero"`` to make a contribution vanish + outside the modeled range). + +.. seealso:: + These arguments are forwarded to each :class:`rocketpy.Function`; see + :meth:`rocketpy.Function.set_interpolation` and + :meth:`rocketpy.Function.set_extrapolation` for the full list of methods. + + diff --git a/requirements.txt b/requirements.txt index 61a594320..4206c8c15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ numpy>=1.13 -scipy>=1.0 +scipy>=1.13.0 # RegularGridInterpolator "pchip"/spline methods (Apr 2024) matplotlib>=3.9.0 # Released May 15th 2024 netCDF4>=1.6.4 requests From a2c617c38ce178c94d782a5f24a784432db6c23e Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 10 Jul 2026 19:45:04 -0300 Subject: [PATCH 7/8] TST: rebuild calisto_linear_generic from extracted Barrowman curves Replace the poorly defined calisto_linear_generic fixture, which kept the Barrowman nose cone and tail and swapped only the fins for a LinearGenericSurface with arbitrary made-up coefficients. The new fixture is a standalone Calisto whose nose cone, tail and fins are all LinearGenericSurfaces built from coefficient curves extracted off the standard Barrowman surfaces (normal-force-curve slope, center of pressure, fin roll damping). Each linear surface applies its force at its own origin and is placed at the source surface's center-of-pressure station, so both the static-margin path and the flight-moment path land at the same point as calisto_robust. The resulting flight matches the standard Calisto (identical apogee, out-of-rail time and ascent angle of attack), so the fixture now exercises the linear generic-surface path against a known-good reference. Also fix test_linear_generic_surface_flight_is_stable to check the angle of attack only during the ascent off the rail: on the rail the freestream speed is ~0 and the angle of attack is reported as a degenerate 90 degrees for any launcher, which previously failed the < 45 assertion. Co-Authored-By: Claude Opus 4.8 --- tests/fixtures/rockets/rocket_fixtures.py | 137 +++++++++++++++++++++- tests/unit/simulation/test_flight.py | 42 ++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/rockets/rocket_fixtures.py b/tests/fixtures/rockets/rocket_fixtures.py index 9cb3caa3c..6ceabf589 100644 --- a/tests/fixtures/rockets/rocket_fixtures.py +++ b/tests/fixtures/rockets/rocket_fixtures.py @@ -1,7 +1,59 @@ import numpy as np import pytest -from rocketpy import Rocket +from rocketpy import LinearGenericSurface, Rocket + +# TODO: review note: gotta test execution speed of changes in this branch + +def _linear_surface_from_barrowman(surface): + """Build a LinearGenericSurface that reproduces a Barrowman surface's aero. + + Reads the coefficient curves off a standard (Barrowman) aerodynamic surface + -- its normal-force-curve slope ``clalpha`` as a function of Mach and, for fin + sets, its roll cant and damping coefficients -- and packs them into the + body-frame coefficient derivatives of an equivalent + :class:`LinearGenericSurface`. The pitch- and yaw-plane slopes follow the + Barrowman sign convention (``cN_alpha = clalpha`` and ``cY_beta = -clalpha``). + + The returned surface applies its force at its own origin (center of pressure + ``(0, 0, 0)``); the caller is expected to add it at the surface's center of + pressure station (``barrowman_station - clalpha_cp``) so that its force + lands, and its static margin reads, at the same point as the Barrowman + surface. Its ``cpz`` (the distance from the surface origin to its center of + pressure) is exposed on the returned object as ``barrowman_cpz`` to make that + offset easy for the caller. + + Parameters + ---------- + surface : rocketpy.NoseCone, rocketpy.Tail or rocketpy fin set + A standard Barrowman aerodynamic surface to copy the aero curves from. + + Returns + ------- + rocketpy.LinearGenericSurface + A linear generic surface with the same normal force and (for fins) roll + behaviour as ``surface``, carrying the source surface's ``cpz`` as + ``barrowman_cpz``. + """ + clalpha = surface.clalpha # normal-force-curve slope, a Function of Mach + coefficients = { + "cN_alpha": clalpha, + "cY_beta": lambda mach: -clalpha.get_value_opt(mach), + } + # Fin sets carry roll coefficients: cant forcing (zero when uncanted) and + # roll-rate damping. Other surfaces have no roll_parameters. + if getattr(surface, "roll_parameters", None) is not None: + coefficients["cl_0"] = surface.cl_0 + coefficients["cl_p"] = surface.cl_p + linear_surface = LinearGenericSurface( + reference_area=surface.reference_area, + reference_length=surface.reference_length, + coefficients=coefficients, + center_of_pressure=(0, 0, 0), + name=f"{surface.name}_linear", + ) + linear_surface.barrowman_cpz = surface.cpz + return linear_surface @pytest.fixture @@ -184,6 +236,89 @@ def calisto_robust( return calisto +@pytest.fixture +def calisto_linear_generic( + cesaroni_m1670, + calisto_nose_cone, + calisto_tail, + calisto_trapezoidal_fins, + calisto_main_chute, + calisto_drogue_chute, +): + """Calisto built entirely from LinearGenericSurfaces instead of Barrowman ones. + + This is the same rocket as ``calisto_robust`` -- same body, motor, rail + buttons and parachutes at the same stations -- but its nose cone, tail and + fin set are each replaced by a body-frame ``LinearGenericSurface``. The + coefficient curves of those linear surfaces are extracted from the matching + standard (Barrowman) surfaces: each linear surface reuses the standard + surface's normal-force-curve slope, center of pressure and (for the fins) + roll damping. Because the aero data is identical, this rocket's flight + closely tracks the standard Calisto's while exercising the linear + generic-surface aerodynamic path -- the one whose forces and moments are + built directly in the body frame from the coefficient derivatives, with no + wind-to-body rotation. It is a standalone rocket (it does not reuse the + shared ``calisto`` fixture), so a test may build both it and ``calisto_robust`` + and compare their flights. + + Parameters + ---------- + cesaroni_m1670 : rocketpy.SolidMotor + The Calisto motor. This is a pytest fixture too. + calisto_nose_cone : rocketpy.NoseCone + The standard nose cone whose aero curves are copied. This is a pytest + fixture too. + calisto_tail : rocketpy.Tail + The standard boat tail whose aero curves are copied. This is a pytest + fixture too. + calisto_trapezoidal_fins : rocketpy.TrapezoidalFins + The standard fin set whose aero curves are copied. This is a pytest + fixture too. + calisto_main_chute : rocketpy.Parachute + The main parachute of the Calisto rocket. This is a pytest fixture too. + calisto_drogue_chute : rocketpy.Parachute + The drogue parachute of the Calisto rocket. This is a pytest fixture too. + + Returns + ------- + rocketpy.Rocket + The Calisto rocket whose nose cone, tail and fins are all + LinearGenericSurfaces. + """ + calisto = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + calisto.add_motor(cesaroni_m1670, position=-1.373) + # Replace each Barrowman surface with an equivalent LinearGenericSurface. A + # Barrowman surface is placed by its origin and carries its center of + # pressure at ``cpz`` aft of that origin; a generic surface applies its force + # at its own origin. So each linear surface is added at the Barrowman + # surface's center-of-pressure station (station - cpz, with tail_to_nose + # csys = +1), which lands its force -- and its static margin -- at the same + # point as calisto_robust. + for surface, station in ( + (calisto_nose_cone, 1.160), + (calisto_tail, -1.313), + (calisto_trapezoidal_fins, -1.168), + ): + linear_surface = _linear_surface_from_barrowman(surface) + calisto.add_surfaces(linear_surface, station - linear_surface.barrowman_cpz) + calisto.set_rail_buttons( + upper_button_position=0.082, + lower_button_position=-0.618, + angular_position=0, + ) + calisto.parachutes.append(calisto_main_chute) + calisto.parachutes.append(calisto_drogue_chute) + return calisto + + @pytest.fixture def calisto_nose_to_tail_robust( calisto_nose_to_tail, diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py index eacd2f3e1..01c9e9f51 100644 --- a/tests/unit/simulation/test_flight.py +++ b/tests/unit/simulation/test_flight.py @@ -7,7 +7,7 @@ import pytest from scipy import optimize -from rocketpy import Components, Flight, Function, Rocket +from rocketpy import Components, Flight, Function, LinearGenericSurface, Rocket plt.rcParams.update({"figure.max_open_warning": 0}) @@ -648,6 +648,46 @@ def test_stability_static_margins( assert np.all(np.abs(moments) <= 1e-10) +def test_linear_generic_surface_flight_is_stable( + calisto_linear_generic, example_plain_env +): + """A Calisto whose fin set is a body-frame LinearGenericSurface flies stably. + + The linear surface builds its forces and moments directly in the body frame + from the coefficient derivatives (no wind-to-body rotation). With a positive + normal-force slope placed aft it must give a positive static margin and the + rocket must reach a finite apogee while staying aligned with the flow (a + small angle of attack, i.e. no tumbling). + """ + rocket = calisto_linear_generic + assert any( + isinstance(surface, LinearGenericSurface) + for surface, _ in rocket.aerodynamic_surfaces + ) + assert rocket.static_margin(0) > 0 + + test_flight = Flight( + environment=example_plain_env, + rocket=rocket, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=True, + ) + + assert test_flight.apogee_time > test_flight.out_of_rail_time + assert np.isfinite(test_flight.apogee) + assert test_flight.apogee > example_plain_env.elevation + # A stable rocket keeps a small angle of attack throughout the ascent. Only + # the ascent off the rail is checked: while the rocket is still on the rail + # its speed is ~0, so the angle of attack is reported as a degenerate 90 + # degrees (arccos of 0) for every launcher, stable or not. + aoa_source = test_flight.angle_of_attack.get_source() + ascent = aoa_source[:, 0] > test_flight.out_of_rail_time + angle_of_attack = aoa_source[ascent, 1] + assert np.nanmax(np.abs(angle_of_attack)) < 45 + + def test_max_acceleration_power_off_time_with_controllers( flight_calisto_air_brakes, ): From fb236c6529be3dc9bf700662c367cab2f867c78c Mon Sep 17 00:00:00 2001 From: MateusStano Date: Sat, 11 Jul 2026 10:07:21 -0300 Subject: [PATCH 8/8] ENH: add force convention to LinearGenericSurface --- .../rocket/aero_surface/generic_surface.py | 36 +++-- .../aero_surface/linear_generic_surface.py | 152 +++++++++++++++++- .../test_linear_generic_surfaces.py | 110 ++++++++++++- 3 files changed, 283 insertions(+), 15 deletions(-) diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index eb1dfac30..decb832b6 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -242,15 +242,13 @@ def __init__( self.force_convention = self._resolve_force_convention( coefficients, force_convention ) - # The wind->body conversion only applies to surfaces whose coefficients - # are the full body-frame forces (cN/cY/cA). The linear model uses - # coefficient derivatives (cN_alpha, ...) whose frame is fixed by name. + # Wind-frame force input (cL/cQ/cD) is converted once to the canonical + # body-frame coefficients before validation. Each surface supplies the + # conversion appropriate to its coefficients: the generic surface rotates + # the full force coefficients, while the linear model recombines the + # coefficient derivatives (see LinearGenericSurface._wind_input_to_body). # A non-dict input falls through to _check_coefficients, which rejects it. - if ( - self.force_convention == "wind" - and "cN" in default_coefficients - and isinstance(coefficients, dict) - ): + if self.force_convention == "wind" and isinstance(coefficients, dict): coefficients = self._wind_input_to_body(coefficients) self._check_coefficients(coefficients, default_coefficients) coefficients = self._complete_coefficients(coefficients, default_coefficients) @@ -505,23 +503,35 @@ def _coefficient_option(option, coeff_name): _WIND_FORCE_NAMES = ("cL", "cQ", "cD") _BODY_FORCE_NAMES = ("cN", "cY", "cA") + def _force_frames_present(self, coefficients): + """Report which force frames the input coefficient names belong to, as + ``(has_wind, has_body)``. + + A generic surface matches the plain force names (``cL``/``cQ``/``cD`` for + wind, ``cN``/``cY``/``cA`` for body). The linear model overrides this to + match those same names as derivative prefixes (``cL_alpha`` ...). + """ + keys = set(coefficients) + has_wind = bool(keys & set(self._WIND_FORCE_NAMES)) + has_body = bool(keys & set(self._BODY_FORCE_NAMES)) + return has_wind, has_body + def _resolve_force_convention(self, coefficients, force_convention): """Decide whether the input force coefficients are given in the wind frame (``cL``/``cQ``/``cD``) or the body frame (``cN``/``cY``/``cA``). When ``force_convention`` is ``None`` the frame is inferred from the - coefficient names; mixing the two frames is rejected. + coefficient names; mixing the two frames is rejected. With no force + coefficients to infer from, the canonical body frame is assumed. """ - keys = set(coefficients) - has_wind = bool(keys & set(self._WIND_FORCE_NAMES)) - has_body = bool(keys & set(self._BODY_FORCE_NAMES)) + has_wind, has_body = self._force_frames_present(coefficients) if force_convention is None: if has_wind and has_body: raise ValueError( "Mixed wind (cL/cQ/cD) and body (cN/cY/cA) force " "coefficients; pass force_convention='wind' or 'body'." ) - return "body" if has_body else "wind" + return "wind" if has_wind else "body" if force_convention not in ("wind", "body"): raise ValueError( f"force_convention must be 'wind' or 'body', got {force_convention!r}." diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index ee92f5cf2..69aa5442e 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -1,6 +1,9 @@ +import inspect + from rocketpy.mathutils import Function from rocketpy.plots.aero_surface_plots import _LinearGenericSurfacePlots from rocketpy.prints.aero_surface_prints import _LinearGenericSurfacePrints +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient from rocketpy.rocket.aero_surface.generic_surface import GenericSurface @@ -22,6 +25,7 @@ def __init__( name="Generic Linear Surface", interpolation=None, extrapolation=None, + force_convention=None, ): """Create a generic linear aerodynamic surface, defined by its aerodynamic coefficients derivatives. This surface is used to model any @@ -35,6 +39,13 @@ def __init__( contain at least one of the following: "alpha", "beta", "mach", "reynolds", "pitch_rate", "yaw_rate" and "roll_rate". + By default the force-coefficient derivatives are the body-frame ones + (``cN_*`` normal, ``cY_*`` side, ``cA_*`` axial; see + ``force_convention``). You may instead give the wind-frame derivatives + ``cL_*`` (lift), ``cQ_*`` (side) and ``cD_*`` (drag) -- for example + ``cL_alpha`` in place of ``cN_alpha``; they are converted once to the + body-frame set at construction. + See Also -------- :ref:`genericsurfaces`. @@ -57,7 +68,10 @@ def __init__( yaw moment ``cn`` or roll moment ``cl``; the variable is ``0`` (the value at zero angle of attack, zero sideslip and zero rates), ``alpha``, ``beta``, ``p`` (roll rate), ``q`` (pitch rate) or ``r`` - (yaw rate). The full list is:\n + (yaw rate). With ``force_convention="wind"`` the force derivatives are + named after the wind-frame coefficients instead (lift ``cL``, side + ``cQ``, drag ``cD`` -- e.g. ``cL_alpha``, ``cD_0``, ``cQ_beta``); the + moment names are unchanged. The full (body-frame) list is:\n cN_0: callable, str, optional Coefficient of normal force at zero angle of attack. Default is 0.\n cN_alpha: callable, str, optional @@ -193,6 +207,21 @@ def __init__( uses ``"constant"`` for tables built here and keeps whatever a pre-built ``Function`` already carries. Only affects tabulated sources (constants and callables are evaluated directly). + force_convention : str, optional + The frame your force-coefficient derivatives are given in. ``"body"`` + for the body-frame derivatives ``cN_*`` (normal), ``cY_*`` (side) and + ``cA_*`` (axial); ``"wind"`` for the aerodynamic-frame derivatives + ``cL_*`` (lift), ``cQ_*`` (side) and ``cD_*`` (drag). The moment + derivatives (``cm_*``, ``cn_*``, ``cl_*``) are the same in both. + ``None`` (the default) infers the frame from the coefficient names you + pass and assumes body when none are given. A wind-frame input is + converted once to the body-frame derivatives the surface stores, by + linearizing the angle-of-attack/sideslip rotation about zero: the + straight renames ``cN_0 = cL_0``, ``cN_beta = cL_beta``, the rate + derivatives, and the cross terms ``cN_alpha = cL_alpha + cD_0``, + ``cY_beta = cQ_beta - cD_0``, ``cA_alpha = cD_alpha - cL_0`` and + ``cA_beta = cD_beta + cQ_0``. At zero angle this reduces to + ``cN = cL``, ``cY = cQ``, ``cA = cD``. """ super().__init__( @@ -203,6 +232,7 @@ def __init__( name=name, extrapolation=extrapolation, interpolation=interpolation, + force_convention=force_convention, ) self.compute_all_coefficients() @@ -271,6 +301,126 @@ def _get_default_coefficients(self): } return default_coefficients + # Body force-coefficient prefix -> wind force-coefficient prefix, used to + # name the accepted wind-frame inputs. The per-plane suffixes (_0, _alpha, + # _beta, _p, _q, _r) and the moment coefficients (cm/cn/cl) are frame-shared. + _BODY_TO_WIND_PREFIX = {"cN": "cL", "cY": "cQ", "cA": "cD"} + + def _force_frames_present(self, coefficients): + """Detect the force frame from the derivative-name prefixes: a wind key + looks like ``cL_alpha``/``cD_0``/``cQ_beta`` and a body key like + ``cN_alpha``/``cA_0``/``cY_beta``. The moment derivatives (``cm_*``, + ``cn_*``, ``cl_*``) are frame-shared and ignored here. + """ + prefixes = {key.split("_", 1)[0] for key in coefficients} + has_wind = bool(prefixes & set(self._WIND_FORCE_NAMES)) + has_body = bool(prefixes & set(self._BODY_FORCE_NAMES)) + return has_wind, has_body + + def _wind_default_coefficient_names(self): + """The valid wind-frame input names: the body defaults with the force + prefixes swapped to wind (``cN_* -> cL_*``, ``cY_* -> cQ_*``, + ``cA_* -> cD_*``); the moment names are unchanged. + """ + names = set() + for key in self._get_default_coefficients(): + prefix, sep, suffix = key.partition("_") + wind_prefix = self._BODY_TO_WIND_PREFIX.get(prefix, prefix) + names.add(f"{wind_prefix}{sep}{suffix}") + return names + + def _wind_input_to_body(self, coefficients): + """Convert wind-frame coefficient derivatives (``cL_*``/``cD_*``/``cQ_*``) + into the canonical body-frame derivatives (``cN_*``/``cY_*``/``cA_*``). + + The full body-frame force coefficients are the wind ones rotated by the + angle of attack and sideslip; linearizing that rotation about + ``alpha = beta = 0`` gives, to first order, a coefficient-derivative map + with four cross-frame terms:: + + cN_alpha = cL_alpha + cD_0 cA_alpha = cD_alpha - cL_0 + cY_beta = cQ_beta - cD_0 cA_beta = cD_beta + cQ_0 + + Every other derivative is a straight rename (``cN_0 = cL_0``, + ``cN_beta = cL_beta``, the rate derivatives ``cN_p = cL_p`` ..., and the + wind side/axial analogues). At zero angle this reduces to ``cN = cL``, + ``cY = cQ``, ``cA = cD``, matching the generic surface. The moment + derivatives (``cm_*``/``cn_*``/``cl_*``) are frame-shared and pass + through unchanged. + """ + invalid = set(coefficients) - self._wind_default_coefficient_names() + if invalid: + raise ValueError( + f"Invalid coefficient name(s) used in key(s): {', '.join(invalid)}. " + "Check the documentation for valid names." + ) + + def wind(name): + return coefficients.get(name, 0) + + body = { + "cN_0": wind("cL_0"), + "cN_alpha": self._combine(wind("cL_alpha"), wind("cD_0"), 1.0, "cN_alpha"), + "cN_beta": wind("cL_beta"), + "cN_p": wind("cL_p"), + "cN_q": wind("cL_q"), + "cN_r": wind("cL_r"), + "cY_0": wind("cQ_0"), + "cY_alpha": wind("cQ_alpha"), + "cY_beta": self._combine(wind("cQ_beta"), wind("cD_0"), -1.0, "cY_beta"), + "cY_p": wind("cQ_p"), + "cY_q": wind("cQ_q"), + "cY_r": wind("cQ_r"), + "cA_0": wind("cD_0"), + "cA_alpha": self._combine(wind("cD_alpha"), wind("cL_0"), -1.0, "cA_alpha"), + "cA_beta": self._combine(wind("cD_beta"), wind("cQ_0"), 1.0, "cA_beta"), + "cA_p": wind("cD_p"), + "cA_q": wind("cD_q"), + "cA_r": wind("cD_r"), + } + # Moment derivatives are the same in both frames; pass them through. + for name, value in coefficients.items(): + if name.split("_", 1)[0] not in self._WIND_FORCE_NAMES: + body[name] = value + return body + + def _as_coefficient(self, source, name): + """Wrap a raw coefficient input as an :class:`AeroCoefficient` over this + surface's variables (used when recombining wind-frame derivatives). + """ + return AeroCoefficient( + source, + unsteady_aero=self._unsteady_aero, + control_variables=self.control_variables, + name=name, + ) + + def _combine(self, first, second, sign, name): + """Return a coefficient equal to ``first + sign * second``. + + When one term is identically zero the other is returned directly (as a + renamed coefficient), so a derivative that is really just a rename keeps + its original, low-dimensional form. Otherwise the two are summed by a + small wrapper evaluated over the full variable tuple. + """ + coeff_first = self._as_coefficient(first, name) + coeff_second = self._as_coefficient(second, name) + if coeff_second.is_zero: + return coeff_first + if coeff_first.is_zero: + return coeff_second if sign > 0 else coeff_second * -1.0 + first_opt = coeff_first.get_value_opt + second_opt = coeff_second.get_value_opt + + def combined(*args): + return first_opt(*args) + sign * second_opt(*args) + + combined.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in self.independent_vars + ) + return self._as_coefficient(combined, name) + _COEFFICIENT_INPUTS = [ "alpha", "beta", diff --git a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py index 8f3095fd9..7bb884220 100644 --- a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py @@ -1,11 +1,14 @@ import pytest -from rocketpy import Function, LinearGenericSurface +from rocketpy import Function, GenericSurface, LinearGenericSurface from rocketpy.mathutils import Vector REFERENCE_AREA = 1 REFERENCE_LENGTH = 1 +# (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) +_ARGS = (0.0, 0.0, 0.5, 1e6, 0.0, 0.0, 0.0) + @pytest.mark.parametrize( "coefficients", @@ -121,3 +124,108 @@ def test_roll_damping_uses_reduced_rate(): # Old (raw-rate) formulation -- identical value: old_damping_scaling = 0.5 * rho * speed * ref_area * ref_length**2 / 2 assert roll_moment == pytest.approx(old_damping_scaling * cl_p * raw_roll) + + +def test_force_convention_inference(): + """The input frame is inferred from the derivative names when + ``force_convention`` is not given, defaulting to body when there are no + force derivatives to infer from.""" + assert LinearGenericSurface(1, 1, {"cN_alpha": 2.0}).force_convention == "body" + assert LinearGenericSurface(1, 1, {"cL_alpha": 2.0}).force_convention == "wind" + # Moment-only / empty input carries no force frame -> body (canonical). + assert LinearGenericSurface(1, 1, {"cm_alpha": 1.0}).force_convention == "body" + assert LinearGenericSurface(1, 1, {}).force_convention == "body" + + +def test_mixed_frame_input_raises(): + """Supplying both wind (cL_*) and body (cN_*) force derivatives without + declaring the frame is rejected.""" + with pytest.raises(ValueError, match="[Mm]ixed"): + LinearGenericSurface(1, 1, {"cL_alpha": 1.0, "cN_0": 1.0}) + + +def test_invalid_wind_coefficient_name_raises(): + """A wind-frame input with an unknown derivative name is rejected.""" + with pytest.raises(ValueError, match="Invalid coefficient name"): + LinearGenericSurface(1, 1, {"cL_gamma": 1.0}, force_convention="wind") + + +def test_wind_derivatives_convert_to_body_first_order(): + """Wind-frame derivatives are converted to the body-frame set by linearizing + the wind/body rotation about zero: the four cross terms plus straight + renames. A nonzero base drag ``cD_0`` couples into the normal- and + axial-force slopes.""" + surface = LinearGenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={ + "cL_0": 0.1, + "cL_alpha": 5.0, + "cD_0": 0.3, + "cD_alpha": 0.2, + "cQ_beta": -4.0, + "cQ_0": 0.05, + "cm_alpha": -2.0, # frame-shared moment, must pass through unchanged + }, + force_convention="wind", + ) + assert surface.force_convention == "wind" + assert surface.cN_0.get_value_opt(*_ARGS) == pytest.approx(0.1) # cL_0 + assert surface.cN_alpha.get_value_opt(*_ARGS) == pytest.approx(5.3) # cL_alpha+cD_0 + assert surface.cY_beta.get_value_opt(*_ARGS) == pytest.approx(-4.3) # cQ_beta-cD_0 + assert surface.cA_0.get_value_opt(*_ARGS) == pytest.approx(0.3) # cD_0 + assert surface.cA_alpha.get_value_opt(*_ARGS) == pytest.approx(0.1) # cD_alpha-cL_0 + assert surface.cA_beta.get_value_opt(*_ARGS) == pytest.approx(0.05) # cD_beta+cQ_0 + assert surface.cm_alpha.get_value_opt(*_ARGS) == pytest.approx(-2.0) # passthrough + + +def test_wind_input_matches_body_input(): + """A wind-frame surface equals the body-frame surface built from the + hand-converted derivatives, at an arbitrary angle.""" + wind = LinearGenericSurface( + 1, + 1, + coefficients={"cL_0": 0.1, "cL_alpha": 5.0, "cD_0": 0.3, "cQ_beta": -4.0}, + force_convention="wind", + ) + body = LinearGenericSurface( + 1, + 1, + coefficients={ + "cN_0": 0.1, + "cN_alpha": 5.3, + "cA_0": 0.3, + "cA_alpha": -0.1, # cD_alpha - cL_0 + "cY_beta": -4.3, + }, + ) + args = (0.02, 0.01, 0.5, 1e6, 0.0, 0.0, 0.0) + for coeff in ("cN", "cY", "cA"): + assert getattr(wind, coeff).get_value_opt(*args) == pytest.approx( + getattr(body, coeff).get_value_opt(*args) + ) + + +def test_wind_linear_matches_generic_surface_to_first_order(): + """The body-frame forces of a wind-input linear surface agree with the + exact rotation used by GenericSurface to first order in the flow angles.""" + coeffs = {"cL_0": 0.1, "cL_alpha": 5.0, "cD_0": 0.3, "cQ_beta": -4.0} + linear = LinearGenericSurface(0.01, 0.1, coeffs, force_convention="wind") + generic = GenericSurface( + 0.01, + 0.1, + coefficients={ + "cL": lambda a, b, m, re, p, q, r: 0.1 + 5.0 * a, + "cD": lambda a, b, m, re, p, q, r: 0.3, + "cQ": lambda a, b, m, re, p, q, r: -4.0 * b, + }, + force_convention="wind", + ) + eps = 1e-3 + for alpha, beta in [(eps, 0.0), (0.0, eps), (eps, eps)]: + args = (alpha, beta, 0.5, 1e6, 0.0, 0.0, 0.0) + for coeff in ("cN", "cY", "cA"): + # Difference is second order in the angle (~1e-6 at eps=1e-3). + assert getattr(linear, coeff).get_value_opt(*args) == pytest.approx( + getattr(generic, coeff).get_value_opt(*args), abs=1e-5 + )