From 32fe61839bfc0b74d68f8e2803f4693edc36d22c Mon Sep 17 00:00:00 2001 From: MateusStano Date: Mon, 22 Jun 2026 21:54:06 -0300 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 + ) From 8fd3b16f872cc68c4ad31e528c2d17ef31757440 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Thu, 23 Jul 2026 13:05:42 -0300 Subject: [PATCH 09/10] ENH: apply review changes --- rocketpy/plots/flight_plots.py | 216 +++-- rocketpy/plots/rocket_plots.py | 186 ++--- rocketpy/prints/flight_prints.py | 85 +- rocketpy/prints/rocket_prints.py | 24 +- .../rocket/aero_surface/_barrowman_surface.py | 13 +- .../rocket/aero_surface/aero_coefficient.py | 165 ++-- .../controllable_generic_surface.py | 44 +- .../rocket/aero_surface/fins/_base_fin.py | 7 + rocketpy/rocket/aero_surface/fins/fin.py | 4 +- .../rocket/aero_surface/generic_surface.py | 485 ++++++----- .../aero_surface/linear_generic_surface.py | 29 +- rocketpy/rocket/helpers.py | 280 +++++++ rocketpy/rocket/rocket.py | 768 ++++++++++++------ rocketpy/simulation/flight.py | 149 ++-- .../simulation/helpers/flight_derivatives.py | 6 + tests/fixtures/rockets/rocket_fixtures.py | 357 +++++++- .../aero_surface/test_aero_coefficient.py | 17 +- .../test_controllable_generic_surface.py | 39 + .../aero_surface/test_generic_surfaces.py | 209 ++++- .../test_surface_coefficient_completeness.py | 2 +- .../test_unsteady_generic_surface.py | 71 -- .../test_generic_calisto_equivalence.py | 69 ++ tests/unit/rocket/test_stability_rework.py | 334 ++++++-- tests/unit/simulation/test_flight.py | 59 ++ 24 files changed, 2649 insertions(+), 969 deletions(-) create mode 100644 rocketpy/rocket/helpers.py delete mode 100644 tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py create mode 100644 tests/unit/rocket/test_generic_calisto_equivalence.py diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 99a528fb7..99e4d2fa8 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -59,8 +59,9 @@ def first_parachute_event_time_index(self): # Consistent red used for the rocket trajectory line across all plots. _TRAJECTORY_COLOR = "#e63946" - # Burnout vertical/drop-line color -- kept separate from the orange dot marker so - # the dashed line stays readable against typical orange and blue plot lines. + # Dark drop-line/vertical-line color shared by the Burnout and Out Of Rail + # events -- kept separate from their (orange / dark-red) dot markers so the + # dashed line stays readable against typical orange and blue plot lines. _BURNOUT_LINE_COLOR = "#4a4a4a" _EVENT_LINE_WIDTH = 1.2 @@ -69,7 +70,7 @@ def first_parachute_event_time_index(self): "Impact": "#ff1f1f", "Apogee": "#46daff", "Burnout": "#ff8121", - "Out Of Rail": "#8b0000", + "Out Of Rail": "#e6c000", } _COLOR_CYCLE = [ "#7de07a", @@ -110,8 +111,9 @@ def _collect_events(self): (t_ev, "Apogee", "o", self._RESERVED_COLORS["Apogee"], 40) ) elif name == "Out Of Rail": + # Same dot shape and size as Burnout; distinguished by color. events.append( - (t_ev, "Out Of Rail", "^", self._RESERVED_COLORS["Out Of Rail"], 30) + (t_ev, "Out Of Rail", "o", self._RESERVED_COLORS["Out Of Rail"], 40) ) elif "Parachute" in name: if name not in parachute_color_map: @@ -186,18 +188,21 @@ def _add_event_markers(self, ax, legend=True): def _add_event_markers_dropline(self, ax, legend=True, y_bottom=None, labels=None): """Event markers on the plotted curve with drop-lines from the y-axis bottom. - For each trigger-once event (excluding Out Of Rail and Landing), draws an - unlabelled dashed vertical line from the axis bottom to the curve value at - that time, and a labelled scatter marker on the curve itself. Apogee is - drawn last so it renders on top of coincident markers. + For each trigger-once event, draws an unlabelled dashed vertical line from + the axis bottom to the curve value at that time, and a labelled scatter + marker on the curve itself. Apogee is drawn last so it renders on top of + coincident markers. By default Out Of Rail and Landing are omitted, but a + caller can draw them by naming them explicitly in ``labels``. Parameters ---------- y_bottom : float or None - Y coordinate for the bottom of drop-lines. When None (default) the + Y coordinate for the bottom of drop-lines. When None (default) the bottom is derived from the minimum of the visible plotted data. labels : set or None - If given, only events whose label is in this set are drawn. + If given, only events whose label is in this set are drawn; an + explicit set also overrides the default omission of Out Of Rail and + Landing (so e.g. ``labels={"Out Of Rail"}`` draws that marker). """ lines = [ln for ln in ax.lines if len(ln.get_xdata()) > 1] if not lines: @@ -217,14 +222,22 @@ def _add_event_markers_dropline(self, ax, legend=True, y_bottom=None, labels=Non deferred_apogee = None for t_ev, label, marker, color, size in self._collect_events(): - if label in ("Out Of Rail", "Landing"): - continue - if labels is not None and label not in labels: + if labels is not None: + # An explicit label set is an opt-in: draw exactly those events, + # including Out Of Rail / Landing when named. + if label not in labels: + continue + elif label in ("Out Of Rail", "Landing"): + # Omitted from the default (unfiltered) set of drop-line markers. continue if not xlim[0] <= t_ev <= xlim[1]: continue y_ev = float(np.interp(t_ev, xdata, ydata)) - line_color = self._BURNOUT_LINE_COLOR if label == "Burnout" else color + line_color = ( + self._BURNOUT_LINE_COLOR + if label in ("Burnout", "Out Of Rail") + else color + ) lw = ( self._EVENT_LINE_WIDTH if label == "Burnout" else self._EVENT_LINE_WIDTH ) @@ -1245,9 +1258,16 @@ def fluid_mechanics_data(self, *, filename=None): # pylint: disable=too-many-st plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) - def stability_and_control_data(self, *, filename=None): # pylint: disable=too-many-statements - """Prints out Rocket Stability and Control parameters graphs available - about the Flight + def stability_margin_data(self, *, filename=None): + """Plots the rocket's stability margin over the flight, in calibers. + + The stability margin is one of the most important results of a + simulation: it is the distance from the center of mass to the center of + pressure that must stay positive (center of pressure behind the center + of mass) for the rocket to correct disturbances. A secondary axis reads + the same margin as a percentage of the rocket's overall length, the + convention often used in hobby rocketry. For a non-axisymmetric rocket + the pitch and yaw margins are drawn separately. Parameters ---------- @@ -1261,21 +1281,20 @@ def stability_and_control_data(self, *, filename=None): # pylint: disable=too-m ------- None """ - - plt.figure(figsize=(9, 6)) - asymmetric = not self.flight.rocket.is_axisymmetric - ax1 = plt.subplot(211) + + plt.figure(figsize=(9, 4.5)) + ax1 = plt.subplot(111) ax1.plot( self.flight.stability_margin[:, 0], self.flight.stability_margin[:, 1], - label="Linear pitch" if asymmetric else "Linear (aerodynamic center)", + label="Pitch" if asymmetric else "Stability margin", ) if asymmetric: ax1.plot( self.flight.stability_margin_yaw[:, 0], self.flight.stability_margin_yaw[:, 1], - label="Linear yaw", + label="Yaw", ) ax1.set_title("Stability Margin") ax1.set_xlabel("Time (s)") @@ -1283,59 +1302,56 @@ def stability_and_control_data(self, *, filename=None): # pylint: disable=too-m ax1.set_xlim(0, self.first_parachute_event_time) ax1.legend() ax1.grid() - self._add_event_markers_dropline(ax1, labels={"Burnout"}) - - ax2 = plt.subplot(212) - x_axis = np.arange(0, 5, 0.01) - max_attitude = self.flight.attitude_frequency_response.max - max_attitude = max_attitude if max_attitude != 0 else 1 - ax2.plot( - x_axis, - self.flight.attitude_frequency_response(x_axis) / max_attitude, - label="Attitude Angle", - ) - max_omega1 = self.flight.omega1_frequency_response.max - max_omega1 = max_omega1 if max_omega1 != 0 else 1 - ax2.plot( - x_axis, - self.flight.omega1_frequency_response(x_axis) / max_omega1, - label=r"$\omega_1$", - ) - max_omega2 = self.flight.omega2_frequency_response.max - max_omega2 = max_omega2 if max_omega2 != 0 else 1 - ax2.plot( - x_axis, - self.flight.omega2_frequency_response(x_axis) / max_omega2, - label=r"$\omega_2$", - ) - max_omega3 = self.flight.omega3_frequency_response.max - max_omega3 = max_omega3 if max_omega3 != 0 else 1 - ax2.plot( - x_axis, - self.flight.omega3_frequency_response(x_axis) / max_omega3, - label=r"$\omega_3$", - ) - ax2.set_title("Frequency Response") - ax2.set_xlabel("Frequency (Hz)") - ax2.set_ylabel("Amplitude Magnitude Normalized") - ax2.set_xlim(0, 5) - ax2.legend() - ax2.grid() + # Secondary y-axis reading the same margin as a percentage of the + # rocket's overall length (see Rocket.length), the convention often used + # in hobby rocketry. A margin in calibers and the same margin as a + # fraction of body length differ only by the constant factor below, so + # the second scale is a plain rescaling of the caliber axis. + # A rocket with no aerodynamic surfaces has no defined length, so the + # percentage-of-length scale can't be drawn; skip it in that case. + rocket = self.flight.rocket + rocket_length = rocket.length if rocket.aerodynamic_surfaces else 0 + if rocket_length > 0: + factor = 2 * rocket.radius / rocket_length * 100 + secondary_axis = ax1.secondary_yaxis( + "right", + functions=(lambda c: c * factor, lambda p: p / factor), + ) + secondary_axis.set_ylabel("Stability Margin (% of length)") + self._add_event_markers_dropline(ax1, labels={"Out Of Rail", "Burnout"}) - plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) - def dynamic_stability_data(self, *, filename=None): + def stability_and_control_data(self, *, filename=None): + """Deprecated. Stability and the frequency response are now separate + plots. + + Use :meth:`stability_margin_data` for the stability margin, and + :meth:`dynamic_stability_data` for the natural frequency, damping ratio + and attitude frequency response. + + Parameters + ---------- + filename : str | None, optional + Passed through to both replacement plots. + """ + warnings.warn( + "stability_and_control_data() is deprecated and will be removed in " + "v1.13. Stability is now its own plot: use stability_margin_data() " + "for the stability margin, and dynamic_stability_data() for the " + "natural frequency, damping ratio and attitude frequency response.", + DeprecationWarning, + stacklevel=2, + ) + self.stability_margin_data(filename=filename) + self.dynamic_stability_data(filename=filename) + + def dynamic_stability_data(self, *, filename=None): # pylint: disable=too-many-statements """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. + damping ratio of the linearized attitude oscillation, together with the + attitude frequency response (the FFT of the simulated oscillation), which + independently verifies the predicted natural frequency. Parameters ---------- @@ -1350,11 +1366,18 @@ def dynamic_stability_data(self, *, filename=None): None """ asymmetric = not self.flight.rocket.is_axisymmetric - upper = self.first_parachute_event_time + # Cap the time axis at apogee: the attitude oscillation is only + # meaningful during ascent. Fall back to the first parachute event (or + # flight end) when there is no apogee, e.g. a flight cut short before it. + upper = ( + self.flight.apogee_time + if self.flight.apogee_time != 0 + else self.first_parachute_event_time + ) - plt.figure(figsize=(9, 6)) + plt.figure(figsize=(9, 9)) - ax1 = plt.subplot(211) + ax1 = plt.subplot(311) freq = self.flight.pitch_natural_frequency ax1.plot(freq[:, 0], freq[:, 1] / (2 * np.pi), label="Pitch natural freq.") if asymmetric: @@ -1365,15 +1388,13 @@ def dynamic_stability_data(self, *, filename=None): "--", 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)", + label="Roll rate", ) ax1.set_title("Natural Frequency & Roll Rate") ax1.set_xlabel("Time (s)") @@ -1381,15 +1402,14 @@ def dynamic_stability_data(self, *, filename=None): ax1.set_xlim(0, upper) ax1.legend() ax1.grid() - self._add_event_markers_dropline(ax1, labels={"Burnout"}) + self._add_event_markers_dropline(ax1, labels={"Out Of Rail", "Burnout"}) - ax2 = plt.subplot(212) + ax2 = plt.subplot(312) 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 (ζ)") @@ -1397,6 +1417,26 @@ def dynamic_stability_data(self, *, filename=None): ax2.legend() ax2.grid() + # Frequency response: the FFT spectrum of the simulated attitude and body + # rates. Its peak should fall at the natural frequency plotted above, + # giving an independent check of the linearized prediction. + ax3 = plt.subplot(313) + x_axis = np.arange(0, 5, 0.01) + for response, label in ( + (self.flight.attitude_frequency_response, "Attitude Angle"), + (self.flight.omega1_frequency_response, r"$\omega_1$"), + (self.flight.omega2_frequency_response, r"$\omega_2$"), + (self.flight.omega3_frequency_response, r"$\omega_3$"), + ): + peak = response.max if response.max != 0 else 1 + ax3.plot(x_axis, response(x_axis) / peak, label=label) + ax3.set_title("Attitude Frequency Response") + ax3.set_xlabel("Frequency (Hz)") + ax3.set_ylabel("Amplitude Magnitude Normalized") + ax3.set_xlim(0, 5) + ax3.legend() + ax3.grid() + plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) @@ -1515,7 +1555,11 @@ def altitude_data(self, *, filename=None): if not xlim[0] <= t_ev <= xlim[1]: continue alt_ev = float(np.interp(t_ev, z_times, z_agl)) - line_color = self._BURNOUT_LINE_COLOR if label == "Burnout" else color + line_color = ( + self._BURNOUT_LINE_COLOR + if label in ("Burnout", "Out Of Rail") + else color + ) lw = ( self._EVENT_LINE_WIDTH if label == "Burnout" else self._EVENT_LINE_WIDTH ) @@ -1827,6 +1871,12 @@ def all(self): # pylint: disable=too-many-statements print("\n\nTrajectory Angular Velocity and Acceleration Plots\n") self.angular_kinematics_data() + print("\n\nStability Margin Plot\n") + self.stability_margin_data() + + print("\n\nDynamic Stability Plots\n") + self.dynamic_stability_data() + print("\n\nAngle of Attack Plots\n") self.angle_of_attack_data() @@ -1851,10 +1901,6 @@ def all(self): # pylint: disable=too-many-statements print("\n\nTrajectory Fluid Mechanics Plots\n") self.fluid_mechanics_data() - 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") self.sensor_data() diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 4cfc0934b..ed7c6d3bb 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -3,12 +3,13 @@ import matplotlib.pyplot as plt import numpy as np +from rocketpy.mathutils.function import Function from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors import EmptyMotor, HybridMotor, LiquidMotor, SolidMotor from rocketpy.rocket.aero_surface import Fin, Fins, NoseCone, Tail from rocketpy.rocket.aero_surface.generic_surface import GenericSurface -from .plot_helpers import show_or_save_plot +from .plot_helpers import show_or_save_fig, show_or_save_plot class _RocketPlots: @@ -55,9 +56,28 @@ def reduced_mass(self): self.rocket.reduced_mass() + def _caliber_to_length_percent(self): + """Return the (forward, inverse) pair converting a margin in calibers to + a percentage of the rocket's overall aerodynamic length. + + A margin in calibers is ``distance / (2 * radius)``; the same distance as + a fraction of the body length is ``distance / length``. The two therefore + differ only by the constant factor ``2 * radius / length`` (times 100 for + a percentage), so the length-percentage scale is a plain rescaling of the + caliber scale and can be drawn as a secondary axis. See + :attr:`rocketpy.Rocket.length`. + """ + factor = 2 * self.rocket.radius / self.rocket.length * 100 + return (lambda calibers: calibers * factor, lambda percent: percent / factor) + def static_margin(self, *, filename=None): """Plots static margin of the rocket as a function of time. + A secondary y-axis on the right expresses the same margin as a + percentage of the rocket's overall length (see + :attr:`rocketpy.Rocket.length`), the convention often used in hobby + rocketry, alongside the primary caliber (diameter) axis. + Parameters ---------- filename : str | None, optional @@ -70,23 +90,49 @@ def static_margin(self, *, filename=None): ------- None """ + self._plot_static_margin(self.rocket.static_margin, "Static Margin", filename) + + def _plot_static_margin(self, margin, title, filename): + """Draw a static-margin-vs-time line plot with a caliber primary y-axis + and a length-percentage secondary y-axis.""" + time = np.linspace(0, self.rocket.motor.burn_out_time, 200) + values = margin.get_value(time) + + fig, ax = plt.subplots() + ax.plot(time, values) + ax.set_xlabel("Time (s)") + ax.set_ylabel("Static Margin (calibers)") + ax.set_title(title) + ax.grid(True) - self.rocket.static_margin(filename=filename) + secondary_axis = ax.secondary_yaxis( + "right", functions=self._caliber_to_length_percent() + ) + secondary_axis.set_ylabel("Static Margin (% of length)") - def stability_margin(self): - """Plots static margin of the rocket as a function of time. + show_or_save_fig(fig, filename) + + def stability_margin(self, *, filename=None): + """Plots the stability margin of the rocket as a function of Mach number + and time, at zero angle of attack (the design surface). + + 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. Returns ------- None """ - - self.rocket.stability_margin.plot_2d( + self._design_stability_margin(self.rocket.stability_margin).plot_2d( lower=0, upper=[2, self.rocket.motor.burn_out_time], # Mach 2 and burnout samples=[20, 20], disp_type="surface", alpha=1, + filename=filename, ) def static_margin_yaw(self, *, filename=None): @@ -94,6 +140,9 @@ def static_margin_yaw(self, *, filename=None): time. Only meaningful for non-axisymmetric rockets; for an axisymmetric rocket it is identical to :meth:`static_margin`. + A secondary y-axis expresses the margin as a percentage of the rocket's + overall length (see :attr:`rocketpy.Rocket.length`). + Parameters ---------- filename : str | None, optional @@ -106,23 +155,42 @@ def static_margin_yaw(self, *, filename=None): ------- None """ - self.rocket.static_margin_yaw(filename=filename) + self._plot_static_margin( + self.rocket.static_margin_yaw, "Static Margin (Yaw Plane)", filename + ) - def stability_margin_yaw(self): + def stability_margin_yaw(self, *, filename=None): """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`. + 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. + Returns ------- None """ - self.rocket.stability_margin_yaw.plot_2d( + self._design_stability_margin(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, + filename=filename, + ) + + @staticmethod + def _design_stability_margin(margin): + """The zero-incidence (Mach, time) slice of an angle-of-attack-aware + stability margin, as a 2-D Function for surface plotting.""" + return Function( + lambda mach, time: margin.get_value_opt(0.0, mach, time), + inputs=["Mach", "Time (s)"], + outputs=margin.__outputs__[0], ) # pylint: disable=too-many-statements @@ -179,51 +247,6 @@ 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. @@ -746,67 +769,15 @@ 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). + the center of pressure. """ # 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.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="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 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`` 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) - positions = [] - for angle in angles: - if plane == "yz": - coeffs = rocket.aerodynamic_coefficients_full(0.0, angle, 0.0) - force, moment = coeffs["cY"], coeffs["cn"] - else: - coeffs = rocket.aerodynamic_coefficients_full(angle, 0.0, 0.0) - force, moment = coeffs["cN"], 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(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, with a vector pointing in the direction normal of the sensor. Get the @@ -888,7 +859,6 @@ def all(self): print("Drag Plots") print("-" * 20) # Separator for Drag Plots self.drag_curves() - self.aerodynamic_coefficients() # Stability Plots print("\nStability Plots") diff --git a/rocketpy/prints/flight_prints.py b/rocketpy/prints/flight_prints.py index 5ead46adc..34e63a22b 100644 --- a/rocketpy/prints/flight_prints.py +++ b/rocketpy/prints/flight_prints.py @@ -612,22 +612,36 @@ def stability_margin(self): The stability margin is typically measured in calibers (c), where 1 caliber is the diameter of the rocket. """ + # The stability margin is reported in calibers and, when the rocket has + # a defined overall length, also as a percentage of that length (the + # convention often used in hobby rocketry). See Rocket.length. + rocket = self.flight.rocket + length = rocket.length if rocket.aerodynamic_surfaces else 0 + to_percent = 2 * rocket.radius / length * 100 if length > 0 else None + + def _margin(value): + """Format a margin in calibers, appending the length percentage when + the rocket length is known.""" + if to_percent is None: + return f"{value:.3f} c" + return f"{value:.3f} c ({value * to_percent:.2f}% of length)" + print("\nStability Margin\n") print( - f"Initial Stability Margin: {self.flight.initial_stability_margin:.3f} c " + f"Initial Stability Margin: {_margin(self.flight.initial_stability_margin)} " f"at {self.flight.time[0]:.2f} s" ) print( "Out of Rail Stability Margin: " - f"{self.flight.out_of_rail_stability_margin:.3f} c " + f"{_margin(self.flight.out_of_rail_stability_margin)} " f"at {self.flight.out_of_rail_time:.2f} s" ) print( - f"Maximum Stability Margin: {self.flight.max_stability_margin:.3f} c " + f"Maximum Stability Margin: {_margin(self.flight.max_stability_margin)} " f"at {self.flight.max_stability_margin_time:.2f} s" ) print( - f"Minimum Stability Margin: {self.flight.min_stability_margin:.3f} c " + f"Minimum Stability Margin: {_margin(self.flight.min_stability_margin)} " f"at {self.flight.min_stability_margin_time:.2f} s" ) @@ -637,20 +651,58 @@ def stability_margin(self): 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" + f"{_margin(self.flight.stability_margin_yaw.get_value_opt(out_of_rail_time))}" ) - # Dynamic stability at rail departure (representative powered condition). + def dynamic_stability(self): + """Prints the rocket's dynamic-stability quantities at the key instants + of the ascent. + + For rail departure and motor burnout it reports the attitude + oscillation's natural frequency and damping ratio (pitch, and yaw as + well for a non-axisymmetric rocket), and it reports the roll rate at + burnout. Companion summary to ``Flight.plots.dynamic_stability_data``. + + Notes + ----- + The natural frequency sets how fast the rocket oscillates after a + disturbance; the damping ratio sets how quickly that oscillation decays + (below 1 is underdamped, the usual case). Roll resonance is a concern + where the roll rate crosses the natural frequency: the + ``dynamic_stability_data`` plot overlays the two so those crossings can + be read off directly. Frequencies are given in Hz. + """ 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}") + flight = self.flight + asymmetric = not flight.rocket.is_axisymmetric + + def report(label, time): + natural_frequency = ( + flight.pitch_natural_frequency.get_value_opt(time) / two_pi + ) + damping_ratio = flight.pitch_damping_ratio.get_value_opt(time) + plane = "Pitch " if asymmetric else "" + print( + f"{label} (t = {time:.2f} s): {plane}natural frequency = " + f"{natural_frequency:.2f} Hz, damping ratio = {damping_ratio:.3f}" + ) + if asymmetric: + yaw_frequency = ( + flight.yaw_natural_frequency.get_value_opt(time) / two_pi + ) + yaw_damping = flight.yaw_damping_ratio.get_value_opt(time) + print( + f" Yaw natural frequency = {yaw_frequency:.2f} Hz, " + f"damping ratio = {yaw_damping:.3f}" + ) + + burn_out_time = flight.rocket.motor.burn_out_time + + print("\nDynamic Stability\n") + report("Out of Rail", flight.out_of_rail_time) + report("Burnout", burn_out_time) + roll_rate = abs(flight.w3.get_value_opt(burn_out_time)) / two_pi + print(f"Roll Rate at Burnout: {roll_rate:.2f} Hz") def all(self): """Prints out all data available about the Flight. This method invokes @@ -689,6 +741,9 @@ def all(self): self.stability_margin() print() + self.dynamic_stability() + print() + self.maximum_values() print() diff --git a/rocketpy/prints/rocket_prints.py b/rocketpy/prints/rocket_prints.py index 72a2daf8e..d9021b6e6 100644 --- a/rocketpy/prints/rocket_prints.py +++ b/rocketpy/prints/rocket_prints.py @@ -129,13 +129,29 @@ def rocket_aerodynamics_quantities(self): f"Aerodynamic Center position (Mach=0): " f"{self.rocket.aerodynamic_center(0):.3f} m" ) + # The static margin is reported in calibers and, when the rocket has a + # defined overall length, also as a percentage of that length (the + # convention often used in hobby rocketry). See Rocket.length. + burn_out_time = self.rocket.motor.burn_out_time + length = self.rocket.length if self.rocket.aerodynamic_surfaces else 0 + to_percent = 2 * self.rocket.radius / length * 100 if length > 0 else None + + def _margin(value): + """Format a margin in calibers, appending the length percentage when + the rocket length is known.""" + if to_percent is None: + return f"{value:.3f} c" + return f"{value:.3f} c ({value * to_percent:.2f}% of length)" + + if length > 0: + print(f"Rocket Length: {length:.3f} m") print( f"Initial Static Margin (mach=0, time=0): " - f"{self.rocket.static_margin(0):.3f} c" + f"{_margin(self.rocket.static_margin(0))}" ) print( f"Final Static Margin (mach=0, time=burn_out): " - f"{self.rocket.static_margin(self.rocket.motor.burn_out_time):.3f} c" + f"{_margin(self.rocket.static_margin(burn_out_time))}" ) print( f"Rocket Center of Mass (time=0) - Aerodynamic Center (Mach=0): " @@ -153,11 +169,11 @@ def rocket_aerodynamics_quantities(self): ) print( f"Initial Static Margin - yaw (mach=0, time=0): " - f"{self.rocket.static_margin_yaw(0):.3f} c" + f"{_margin(self.rocket.static_margin_yaw(0))}" ) 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" + f"{_margin(self.rocket.static_margin_yaw(burn_out_time))}\n" ) def parachute_data(self): diff --git a/rocketpy/rocket/aero_surface/_barrowman_surface.py b/rocketpy/rocket/aero_surface/_barrowman_surface.py index addc41364..dc161b9e6 100644 --- a/rocketpy/rocket/aero_surface/_barrowman_surface.py +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -96,9 +96,7 @@ def evaluate_coefficients(self): # 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_alpha = self._mach_coefficient(clalpha.get_value_opt, "cN_alpha") self.cY_beta = self._mach_coefficient( lambda mach: -clalpha.get_value_opt(mach), "cY_beta" ) @@ -116,9 +114,7 @@ def evaluate_coefficients(self): 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" - ) + self.cl_p = self._mach_coefficient(cld_omega.get_value_opt, "cl_p") def compute_forces_and_moments( self, @@ -160,8 +156,8 @@ def compute_forces_and_moments( 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. + the generic surface (``density``, ``dynamic_viscosity``, ``z``); + unused by the Barrowman model. Returns ------- @@ -225,7 +221,6 @@ def _mach_coefficient(self, func_of_mach, name="coefficient"): return AeroCoefficient( func_of_mach, depends_on=("mach",), - 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 264b49a84..09ebe5445 100644 --- a/rocketpy/rocket/aero_surface/aero_coefficient.py +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -16,20 +16,15 @@ ] -def build_independent_vars(unsteady_aero=False, control_variables=()): +def build_independent_vars(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. + The seven base axes (``BASE_INDEPENDENT_VARS``), 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 + return list(BASE_INDEPENDENT_VARS) + list(control_variables) class AeroCoefficient: @@ -40,7 +35,6 @@ def __init__( self, source, depends_on=None, - unsteady_aero=False, control_variables=(), name="coefficient", extrapolation=None, @@ -90,26 +84,17 @@ def __init__( 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 + ``"roll_rate"``, 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 - 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 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 ``()``. + a controller. They are added after the seven base 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 appears in error messages, @@ -142,18 +127,13 @@ def __init__( 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) - ) + # ``control_variables`` completes 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. + self.independent_vars = tuple(build_independent_vars(control_variables)) # Infer the stored source and its dependencies from the raw input when # ``depends_on`` is not given. if depends_on is None: @@ -429,8 +409,8 @@ def _infer_single_var(function, independent_vars): 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". + # label containing a longer variable name binds to it rather than to a + # shorter variable that happens to be a substring of it. for var in independent_vars: if var == label_lower: return var @@ -517,7 +497,6 @@ def __mul__(self, other): return AeroCoefficient( source * other, self.depends_on, - self.unsteady_aero, self.control_variables, self.name, extrapolation=self.extrapolation, @@ -526,31 +505,6 @@ def __mul__(self, other): __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, - "interpolation": self.interpolation, - } - - @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"), - interpolation=data.get("interpolation"), - ) - def __repr__(self): """Return a concise representation showing the constant or dependencies.""" if self._constant is not None: @@ -614,3 +568,88 @@ def sliced(*values): for var in free_variables ) return Function(sliced, list(free_variables), [self.name]) + + def slope(self, variable, *free_variables, at=None, dx=1e-6): + """Return the derivative of this coefficient with respect to one variable + as a :class:`Function` of the chosen free variables. + + This is the aerodynamic slope, such as a lift-curve slope. For example, + ``cL.slope("alpha", "mach")`` is the lift-curve slope ``d(cL)/d(alpha)`` + as a function of Mach, taken at ``alpha = 0`` with sideslip, Reynolds + number and the rotation rates held at zero. + + Parameters + ---------- + variable : str + Name of the variable to differentiate with respect to (for example + ``"alpha"`` or ``"beta"``). Must be one of this coefficient's + independent variables, and must not also appear in + ``free_variables``. + *free_variables : str + Names of the variables to keep as inputs of the resulting slope, in + the order you want them (for example ``"mach"``). Each must be one of + this coefficient's independent variables. Leave empty to get the + slope at a single point. + at : dict, optional + Values to hold the remaining variables at, keyed by variable name. + The value for ``variable`` is the point the derivative is taken at + (default 0, the linearization point). Any variable not listed is held + at 0. + dx : float, optional + Step size used for the numerical differentiation. Default 1e-6. + + Returns + ------- + Function + A Function of ``free_variables`` giving ``d(self)/d(variable)`` with + the remaining variables held fixed. + """ + fixed = dict(at or {}) + overlap = [var for var in free_variables if var == variable] + if overlap: + raise ValueError( + f"{variable!r} cannot be both differentiated and kept free." + ) + # Point to differentiate at, defaulting to the linearization point (0). + diff_point = fixed.pop(variable, 0.0) + name = f"d({self.name})/d({variable})" + + def evaluate_slope(*free_values): + # Hold the fixed variables and the current free-variable values, + # keep only ``variable`` free, and differentiate along it. + slice_at = dict(fixed) + for var, value in zip(free_variables, free_values): + slice_at[var] = value + return self.slice(variable, at=slice_at).differentiate(diff_point, dx=dx) + + if not free_variables: + return Function(evaluate_slope(), name) + + evaluate_slope.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in free_variables + ) + return Function(evaluate_slope, list(free_variables), [name]) + + 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), + "control_variables": list(self.control_variables), + "name": self.name, + "extrapolation": self.extrapolation, + "interpolation": self.interpolation, + } + + @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("control_variables", ()), + data["name"], + extrapolation=data.get("extrapolation"), + interpolation=data.get("interpolation"), + ) diff --git a/rocketpy/rocket/aero_surface/controllable_generic_surface.py b/rocketpy/rocket/aero_surface/controllable_generic_surface.py index 52ad1ae1f..f312d95c6 100644 --- a/rocketpy/rocket/aero_surface/controllable_generic_surface.py +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -1,4 +1,5 @@ from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +from rocketpy.tools import from_hex_decode, to_hex_encode class ControllableGenericSurface(GenericSurface): @@ -58,8 +59,10 @@ def __init__( center_of_pressure=(0, 0, 0), name="Controllable Generic Surface", controls=("deflection",), + reynolds_length=None, extrapolation=None, interpolation=None, + active_during="always", ): """Create a controllable generic aerodynamic surface. @@ -84,6 +87,10 @@ def __init__( 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`. + reynolds_length : int, float, optional + Length scale, in meters, of the Reynolds number passed to the + coefficients. See :class:`GenericSurface`. ``None`` (the default) + uses ``reference_length`` (the diameter). extrapolation : str or dict, optional What tabulated coefficients do outside their data range: ``"constant"`` holds the nearest edge value, ``"natural"`` keeps @@ -98,6 +105,11 @@ def __init__( 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. + active_during : str or callable, optional + When this surface produces force during a simulation: ``"always"`` + (default), ``"power_on"`` (only while the motor burns, e.g. jet + vanes), ``"power_off"`` (only after burnout), or a function + ``active_during(t, flight)``. See :class:`GenericSurface` for details. """ # These must be set before ``super().__init__`` so coefficient # processing (arity, CSV validation) and the derived-cp accessors see @@ -112,8 +124,10 @@ def __init__( coefficients=coefficients, center_of_pressure=center_of_pressure, name=name, + reynolds_length=reynolds_length, extrapolation=extrapolation, interpolation=interpolation, + active_during=active_during, ) # ``self.prints``/``self.plots`` are the generic ones wired by the base. @@ -126,12 +140,9 @@ def _coefficient_arguments( 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).""" + ``self.control_variables`` order) to the standard inputs.""" base = super()._coefficient_arguments( alpha, beta, @@ -140,8 +151,6 @@ def _coefficient_arguments( 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 @@ -176,6 +185,16 @@ def get_control(self, name): def to_dict( # pylint: disable=unused-argument self, include_outputs=False, **kwargs ): + # A preset ``active_during`` is stored as is; a custom (t, flight) -> bool + # function is pickled to text when allowed, otherwise dropped to "always" + # (a function cannot be restored without pickling). + active_during = self.active_during + if callable(active_during): + active_during = ( + to_hex_encode(active_during) + if kwargs.get("allow_pickle", True) + else "always" + ) return { "reference_area": self.reference_area, "reference_length": self.reference_length, @@ -190,10 +209,21 @@ def to_dict( # pylint: disable=unused-argument "center_of_pressure": self.center_of_pressure, "name": self.name, "controls": self.control_variables, + "reynolds_length": self.reynolds_length, + "active_during": active_during, } @classmethod def from_dict(cls, data): + # A preset ``active_during`` is used as is; anything else is unpickled + # back into the original function (falling back to "always" if it cannot + # be restored). + active_during = data.get("active_during", "always") + if active_during not in ("always", "power_on", "power_off"): + try: + active_during = from_hex_decode(active_during) + except (TypeError, ValueError): + active_during = "always" return cls( reference_area=data["reference_area"], reference_length=data["reference_length"], @@ -201,4 +231,6 @@ def from_dict(cls, data): center_of_pressure=data.get("center_of_pressure", (0, 0, 0)), name=data.get("name", "Controllable Generic Surface"), controls=data.get("controls", ("deflection",)), + reynolds_length=data.get("reynolds_length"), + active_during=active_during, ) diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py index 9b2b7a536..0f2fa7864 100644 --- a/rocketpy/rocket/aero_surface/fins/_base_fin.py +++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py @@ -9,6 +9,13 @@ from ..linear_generic_surface import LinearGenericSurface +# TODO: review note: airfoil handling can now be fully implemented. That is +# instead of getting just the clalpha from the airfoil, we can get and use the +# full lift curve. We need to check if simulation does not break with this +# change. If we use a full curve for airfoils, then we will have fin stall +# (abrupt cN drop), but the other surfaces do not have this drop, they simply +# will have their generated normal forces grow linearly with AoA, this can lead +# to wrong behaviour at high AoA. class _BaseFin(_BarrowmanSurface): """ Base class for fins, shared by both Fin and Fins classes. diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py index c9ca3e490..f5d71e932 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -380,8 +380,8 @@ def compute_forces_and_moments( 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. + the generic surface (e.g. ``density``, ``dynamic_viscosity``, + ``z``). Unused by the fin's Barrowman model. Returns ------- diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index decb832b6..174a95836 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,4 +1,3 @@ -import copy import inspect import math @@ -12,6 +11,7 @@ AeroCoefficient, build_independent_vars, ) +from rocketpy.tools import from_hex_decode, to_hex_encode def _as_function(func, independent_vars, name): @@ -108,10 +108,11 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Surface", - unsteady_aero=False, + reynolds_length=None, interpolation=None, extrapolation=None, force_convention=None, + active_during="always", ): """Create a generic aerodynamic surface, defined by its aerodynamic coefficients. This surface is used to model any aerodynamic surface @@ -126,18 +127,19 @@ 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 Reynolds number ("reynolds") is by default built on the reference + length (the rocket diameter). Published rocket data and tools often base + Reynolds on the **body length** instead, which for a slender rocket is + much larger (Re scales with the chosen length). If your coefficient + table uses a different length than the reference length, pass that + length as ``reynolds_length`` so the Reynolds number the simulation + feeds your table matches the one it was built against. 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. + conventional **non-dimensional reduced rates**, + ``q* = q * L_ref / (2 * V)`` (and likewise for ``r``/``p``). + Provide coefficient tables against the reduced rates, not the raw body + rates in rad/s. See Also -------- @@ -149,8 +151,10 @@ def __init__( Reference area of the aerodynamic surface. Has the unit of meters squared. Commonly defined as the rocket's cross-sectional area. reference_length : int, float - Reference length of the aerodynamic surface. Has the unit of meters. - Commonly defined as the rocket's diameter. + Reference length of the aerodynamic surface, in meters. Commonly the + rocket's diameter. Used to non-dimensionalize the moment coefficients + and the reduced rotation rates, and (unless ``reynolds_length`` is + given) as the length scale of the Reynolds number. coefficients: dict 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 @@ -176,12 +180,13 @@ def __init__( aerodynamic surface. The default value is (0, 0, 0). name : str, optional 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. Default is False. + reynolds_length : int, float, optional + Length scale, in meters, of the Reynolds number passed to the + coefficients. Set it to the length your Reynolds-dependent + coefficient data was tabulated against (for example the rocket's + body length, if your table uses a length-based Reynolds number). + ``None`` (the default) uses ``reference_length`` (the diameter). Has + no effect unless a coefficient actually depends on "reynolds". interpolation : str or dict, optional How tabulated coefficients interpolate between points. The accepted methods depend on the coefficient's dimensionality: a 1-D table @@ -191,77 +196,72 @@ def __init__( 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. + ``"cubic"`` and ``"akima"`` to ``"pchip"``). Pass a single string to + use that method for every coefficient, or a dict keyed by coefficient + name to set them individually (coefficients left out of the dict 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 + Pass a single string to use that method for every coefficient, or a + dict keyed by coefficient name to set them individually (coefficients + left out of the dict 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 + wind-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 + 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). + active_during : str or callable, optional + When this surface produces aerodynamic force during a simulation. + Use it to model a surface that is only present in part of the flight, + such as jet vanes that only work while the motor burns, or a base + drag that only appears after burnout. Accepts: + + - ``"always"`` (default): the surface always contributes force. + - ``"power_on"``: only while the motor is burning (up to the motor's + burn-out time). + - ``"power_off"``: only after the motor has burned out. + - a function ``active_during(t, flight)`` returning ``True`` when the + surface is active at time ``t`` (in seconds) of the given + :class:`Flight`. Use this for any custom window. """ - self._unsteady_aero = unsteady_aero # 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, plus any ``control_variables`` - self.independent_vars = build_independent_vars( - self._unsteady_aero, self.control_variables - ) + # base axes, plus any ``control_variables`` + self.independent_vars = build_independent_vars(self.control_variables) self.reference_area = reference_area self.reference_length = reference_length + self.reynolds_length = ( + reference_length if reynolds_length is None else reynolds_length + ) self.center_of_pressure = center_of_pressure self.cp = center_of_pressure self.cpx = center_of_pressure[0] self.cpy = center_of_pressure[1] self.cpz = center_of_pressure[2] self.name = name + self.active_during = self._validate_active_during(active_during) + self.is_active = self._build_activation_check(self.active_during) 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 + self._build_coefficients( + coefficients, interpolation, extrapolation, force_convention ) - # 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 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(): - value = AeroCoefficient( - coeff_value, - 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_stability_derivatives() @@ -280,6 +280,47 @@ def _default_surface_rotation(self): """ return Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + @staticmethod + def _validate_active_during(active_during): + """Check the ``active_during`` policy and return it unchanged. + + Accepts one of the preset strings ``"always"``, ``"power_on"``, + ``"power_off"`` or a callable ``(t, flight) -> bool``; anything else + raises a ``ValueError`` so a typo is caught at construction rather than + silently keeping the surface active. + """ + if callable(active_during) or active_during in ( + "always", + "power_on", + "power_off", + ): + return active_during + raise ValueError( + "`active_during` must be one of 'always', 'power_on', 'power_off' " + "or a callable(t, flight) -> bool; " + f"got {active_during!r}." + ) + + @staticmethod + def _build_activation_check(active_during): + """Resolve an ``active_during`` policy into the ``is_active(t, flight)`` + function the flight integrator calls for every surface each step to skip + the ones that are not currently active. + + Resolving it once here keeps that per-step check free of policy + branching. A custom callable is used unchanged; each preset becomes a + small function of the simulation time ``t`` (in seconds) and the + ``flight`` being run, and ``"always"`` becomes a function that simply + returns ``True``. + """ + if callable(active_during): + return active_during + if active_during == "power_on": + return lambda t, flight: t < flight.rocket.motor.burn_out_time + if active_during == "power_off": + return lambda t, flight: t >= flight.rocket.motor.burn_out_time + return lambda t, flight: True # "always" + @property def force_application_point(self): """Local point (surface frame) at which the resultant force is applied @@ -290,7 +331,7 @@ def force_application_point(self): return Vector([self.cpx, self.cpy, self.cpz]) @property - def cL(self): # pylint: disable=invalid-name + def cL(self): """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.""" @@ -299,40 +340,19 @@ def cL(self): # pylint: disable=invalid-name )[0] @property - def cD(self): # pylint: disable=invalid-name + def cD(self): """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 + def cQ(self): """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. - - 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 @@ -362,41 +382,31 @@ def _evaluate_stability_derivatives(self): ------- None """ - 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 - ---------- - 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, + self.cN_alpha = AeroCoefficient( + self.cN.slope("alpha", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cN_alpha", + ) + self.cm_alpha = AeroCoefficient( + self.cm.slope("alpha", "mach"), depends_on=("mach",), - unsteady_aero=self._unsteady_aero, control_variables=self.control_variables, - name=name, + name="cm_alpha", ) + self.cY_beta = AeroCoefficient( + self.cY.slope("beta", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cY_beta", + ) + self.cn_beta = AeroCoefficient( + self.cn.slope("beta", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cn_beta", + ) + self._set_stability_accessors() def _set_stability_accessors(self): """Build the pitch- and yaw-plane center-of-pressure accessors from the @@ -432,49 +442,6 @@ def cp_z(mach): 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`` - 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") - @staticmethod def _coefficient_option(option, coeff_name): """Resolve a per-coefficient interpolation/extrapolation setting. @@ -553,7 +520,6 @@ def _wind_input_to_body(self, coefficients): def as_coefficient(source, name): return AeroCoefficient( source, - unsteady_aero=self._unsteady_aero, control_variables=self.control_variables, name=name, ) @@ -566,6 +532,60 @@ def as_coefficient(source, name): ) return {"cN": c_normal, "cY": c_yaw, "cA": c_axial, **passthrough} + def _build_coefficients( + self, coefficients, interpolation, extrapolation, force_convention + ): + """Resolve the force-coefficient frame and store the surface's + aerodynamic coefficients as :class:`AeroCoefficient` attributes. + + Runs the full coefficient setup from the user input: picks the force + frame, converts a wind-frame input to the canonical body frame, fills in + any coefficient the user left out with its default (0), and stores each + one as an attribute (``self.cN``, ``self.cm``, ...). + + Parameters + ---------- + coefficients : dict + The user-provided coefficients (see :meth:`__init__`). + interpolation, extrapolation : str, dict, or None + The interpolation/extrapolation settings (see :meth:`__init__`). + force_convention : str or None + The frame the input force coefficients are given in, or ``None`` to + infer it from the coefficient names. + """ + default_coefficients = self._get_default_coefficients() + self.force_convention = self._resolve_force_convention( + coefficients, force_convention + ) + # 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 isinstance(coefficients, dict): + coefficients = self._wind_input_to_body(coefficients) + self._check_coefficients(coefficients, default_coefficients) + coefficients = self._complete_coefficients(coefficients, default_coefficients) + + # ``_needs_reynolds`` lets the flight loop skip the per-step atmosphere + # lookups when no coefficient uses the Reynolds number. Only these + # primary coefficients are checked: they are what the surface evaluates, + # and the linear model's combined coefficients are linear combinations of + # them, so a Reynolds dependence always shows up here. + self._needs_reynolds = False + for coeff, coeff_value in coefficients.items(): + value = AeroCoefficient( + coeff_value, + control_variables=self.control_variables, + name=coeff, + extrapolation=self._coefficient_option(extrapolation, coeff), + interpolation=self._coefficient_option(interpolation, coeff), + ) + setattr(self, coeff, value) + if "reynolds" in value.depends_on: + self._needs_reynolds = True + def _get_default_coefficients(self): """Returns default coefficients @@ -603,9 +623,13 @@ def _complete_coefficients(self, input_coefficients, default_coefficients): coefficients : dict Coefficients dictionary used to setup coefficient attributes """ - coefficients = copy.deepcopy(input_coefficients) + # Shallow copy: only missing keys are added, so the user's dict is left + # intact. The values are not mutated here (each is wrapped in an + # AeroCoefficient, which copies it when it needs its own settings), so + # there is no need to deep-copy potentially large tabulated coefficients. + coefficients = dict(input_coefficients) for coeff, value in default_coefficients.items(): - if coeff not in coefficients.keys(): + if coeff not in coefficients: coefficients[coeff] = value return coefficients @@ -645,8 +669,6 @@ 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. @@ -671,12 +693,6 @@ 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 ------- @@ -689,8 +705,7 @@ def _compute_from_coefficients( dyn_pressure_area_length = dyn_pressure_area * self.reference_length # Coefficient arguments (base 7 vars, plus any extra axes appended by - # subclasses such as control deflections or the unsteady alpha_dot/ - # beta_dot terms). + # subclasses such as control deflections). args = self._coefficient_arguments( alpha, beta, @@ -699,8 +714,6 @@ def _compute_from_coefficients( pitch_rate, yaw_rate, roll_rate, - alpha_dot, - beta_dot, ) # Body-frame force components straight from the body-frame coefficients @@ -708,16 +721,16 @@ def _compute_from_coefficients( 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 + 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 r1, r2, r3, pitch, yaw, roll + return R1, R2, R3, pitch, yaw, roll def _coefficient_arguments( self, @@ -728,19 +741,13 @@ def _coefficient_arguments( 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`) + standard inputs. 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 + return (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) def compute_forces_and_moments( self, @@ -753,8 +760,6 @@ 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 @@ -783,12 +788,6 @@ 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 ------- @@ -797,16 +796,24 @@ def compute_forces_and_moments( (pitch, yaw, roll) in the body frame. """ # Reynolds number at the surface altitude. Computed here (rather than in - # the flight loop) since it is only needed by generic surfaces. - comp_density = density.get_value_opt(z) - comp_dynamic_viscosity = dynamic_viscosity.get_value_opt(z) - reynolds = ( - comp_density * stream_speed * self.reference_length / comp_dynamic_viscosity - if comp_dynamic_viscosity > 0 - else 0 - ) + # the flight loop) since it is only needed by generic surfaces, and only + # when a coefficient actually depends on it -- otherwise the two + # atmosphere lookups are skipped for every surface, every step. + if self._needs_reynolds: + comp_density = density.get_value_opt(z) + comp_dynamic_viscosity = dynamic_viscosity.get_value_opt(z) + reynolds = ( + comp_density + * stream_speed + * self.reynolds_length + / comp_dynamic_viscosity + if comp_dynamic_viscosity > 0 + else 0 + ) + else: + reynolds = 0.0 - # Stream velocity in standard aerodynamic frame + # Stream velocity in standard wind frame stream_velocity = -stream_velocity # Angles of attack and sideslip @@ -833,11 +840,81 @@ def compute_forces_and_moments( 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, ) # Dislocation of the aerodynamic application point to CDM M1, M2, M3 = Vector([pitch, yaw, roll]) + (cp ^ Vector([R1, R2, R3])) return R1, R2, R3, M1, M2, M3 + + def to_dict(self, include_outputs=False, **kwargs): # pylint: disable=unused-argument + # The stored coefficients are always the canonical body-frame set (the + # names from ``_get_default_coefficients``: cN/cY/cA/... for a generic + # surface, the derivative set for the linear model), so they are saved + # with ``force_convention="body"`` and rebuilt directly on load. + coefficients = { + name: getattr(self, name) for name in self._get_default_coefficients() + } + # A preset ``active_during`` is stored as is; a custom (t, flight) -> bool + # function is pickled to text when allowed, otherwise dropped to "always" + # (a function cannot be restored without pickling). + active_during = self.active_during + if callable(active_during): + active_during = ( + to_hex_encode(active_during) + if kwargs.get("allow_pickle", True) + else "always" + ) + return { + "reference_area": self.reference_area, + "reference_length": self.reference_length, + "reynolds_length": self.reynolds_length, + "coefficients": coefficients, + "center_of_pressure": self.center_of_pressure, + "name": self.name, + "force_convention": "body", + "active_during": active_during, + } + + @classmethod + def from_dict(cls, data): + # A preset ``active_during`` is used as is; anything else is unpickled + # back into the original function (falling back to "always" if it cannot + # be restored). + active_during = data.get("active_during", "always") + if active_during not in ("always", "power_on", "power_off"): + try: + active_during = from_hex_decode(active_during) + except (TypeError, ValueError): + active_during = "always" + 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", "Generic Surface"), + reynolds_length=data.get("reynolds_length"), + force_convention=data.get("force_convention", "body"), + active_during=active_during, + ) + + 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() diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index 69aa5442e..3b313bfef 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -23,9 +23,11 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Linear Surface", + reynolds_length=None, interpolation=None, extrapolation=None, force_convention=None, + active_during="always", ): """Create a generic linear aerodynamic surface, defined by its aerodynamic coefficients derivatives. This surface is used to model any @@ -185,6 +187,10 @@ def __init__( name : str, optional Name of the aerodynamic surface. Default is 'Generic Linear Surface'. + reynolds_length : int, float, optional + Length scale, in meters, of the Reynolds number passed to the + coefficient derivatives. See :class:`GenericSurface`. ``None`` (the + default) uses ``reference_length`` (the diameter). interpolation : str or dict, optional How tabulated coefficient derivatives interpolate between points. The accepted methods depend on the coefficient's dimensionality: a @@ -210,7 +216,7 @@ def __init__( 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 + ``cA_*`` (axial); ``"wind"`` for the wind-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 @@ -222,6 +228,12 @@ def __init__( ``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``. + active_during : str or callable, optional + When this surface produces aerodynamic force during a simulation: + ``"always"`` (default), ``"power_on"`` (only while the motor burns), + ``"power_off"`` (only after burnout), or a function + ``active_during(t, flight)`` returning ``True`` when the surface is + active at time ``t``. See :class:`GenericSurface` for details. """ super().__init__( @@ -230,9 +242,11 @@ def __init__( coefficients=coefficients, center_of_pressure=center_of_pressure, name=name, + reynolds_length=reynolds_length, extrapolation=extrapolation, interpolation=interpolation, force_convention=force_convention, + active_during=active_during, ) self.compute_all_coefficients() @@ -390,7 +404,6 @@ def _as_coefficient(self, source, name): """ return AeroCoefficient( source, - unsteady_aero=self._unsteady_aero, control_variables=self.control_variables, name=name, ) @@ -567,7 +580,6 @@ def total_coefficient( def compute_all_coefficients(self): """Compute all the aerodynamic coefficients from the derivatives.""" - # pylint: disable=invalid-name self.cNf = self.compute_forcing_coefficient( self.cN_0, self.cN_alpha, self.cN_beta ) @@ -616,15 +628,10 @@ 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 @@ -645,12 +652,6 @@ 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 ------- diff --git a/rocketpy/rocket/helpers.py b/rocketpy/rocket/helpers.py new file mode 100644 index 000000000..26593c832 --- /dev/null +++ b/rocketpy/rocket/helpers.py @@ -0,0 +1,280 @@ +"""Helper functions backing some :class:`rocketpy.Rocket` methods. + +These carry the heavier computations behind the rocket's full-body aerodynamic +reduction (``to_coefficients`` / ``to_surface``), kept out of ``rocket.py`` so +that module stays focused on the rocket's public interface. Each function takes +the rocket it operates on as its first argument. +""" + +import math + +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient + + +def zero_drag(rocket, which): + """Set one of the rocket's built-in drag curves to zero. ``which`` is + ``"power_on"`` or ``"power_off"``. Rebuilds the ``*_drag_7d``, + ``*_drag_by_mach`` and the public ``*_drag`` alias to match, mirroring how + they are built in ``Rocket.__init__``. + """ + label = "Power On" if which == "power_on" else "Power Off" + setattr( + rocket, + f"{which}_drag_7d", + AeroCoefficient( + 0, + name=f"Drag Coefficient with {label}", + extrapolation="constant", + single_var="mach", + ), + ) + by_mach = Function( + lambda mach: 0.0, + inputs="Mach Number", + outputs=f"Drag Coefficient with {label}", + interpolation="linear", + extrapolation="constant", + ) + setattr(rocket, f"{which}_drag_by_mach", by_mach) + setattr(rocket, f"{which}_drag", by_mach) + setattr(rocket, f"_{which}_drag_input", 0) + + +def summed_force_and_moment(rocket, alpha, beta, mach, omega, speed=1.0): + """Total body-frame force ``(R1, R2, R3)`` and moment ``(M1, M2, M3)`` about + the center of dry mass, summed over every aerodynamic surface of ``rocket`` + at a flow state and set of body rates. + + Mirrors the per-surface computation the flight integrator performs: each + surface is fed its own local stream velocity, which includes the + ``omega x cp`` lever-arm term, so the sum captures the pitch and yaw damping + the distributed surfaces produce through their fore-and-aft positions. + Evaluated at unit air density; the result scales out of any dimensionless + coefficient, and the chosen ``speed`` cancels from every coefficient built + from it. ``omega`` is the body angular rate in rad/s. + """ + stream_direction = Vector([-math.tan(beta), -math.tan(alpha), -1.0]) + stream_at_cdm = stream_direction / abs(stream_direction) * speed + body_rates = Vector(list(omega)) + density = Function(1.0) + dynamic_viscosity = Function(1e30) # vanishing-Reynolds limit + speed_of_sound = speed / mach if mach > 0 else 1e30 + totals = np.zeros(6) + for surface, _ in rocket.aerodynamic_surfaces: + cp = rocket.surfaces_cp_to_cdm[surface] + comp_stream = stream_at_cdm - (body_rates ^ cp) + comp_speed = abs(comp_stream) + forces = surface.compute_forces_and_moments( + comp_stream, + comp_speed, + comp_speed / speed_of_sound, + 1.0, + cp, + body_rates, + density, + dynamic_viscosity, + 0.0, + ) + totals += np.array(forces) + return totals + + +def neutral_point_and_slope(rocket, alpha, beta, mach, plane="pitch", step=1e-4): + """Local (tangent) neutral point and force-curve slope at a finite incidence. + + Generalizes the aerodynamic center to a non-zero angle of attack. The neutral + point is the point about which the aerodynamic moment does not change for a + *small* perturbation of the incidence angle around the given ``(alpha, beta)`` + state, i.e. the tangent of the moment-versus-force curve at that state. It is + obtained by central-differencing the rocket's summed body-frame force and + moment (about the center of dry mass) with respect to the plane's incidence + angle, then forming ``x_cdm + csys * L_ref * (dCm/da) / (dCN/da)``. + + For a rocket whose surfaces are all linear in incidence (the built-in + Barrowman surfaces) the result is independent of ``alpha``/``beta`` and equals + :attr:`rocketpy.Rocket.aerodynamic_center`. It moves with incidence only when + a surface's normal-force coefficient is nonlinear in the incidence angle (for + example a Galejs ``sin**2(alpha)`` body-lift term added as a + :class:`rocketpy.GenericSurface`). + + Parameters + ---------- + rocket : rocketpy.Rocket + The rocket to evaluate. + alpha, beta : float + Angle of attack and sideslip angle, in radians, defining the state the + neutral point is taken about. + mach : float + Free-stream Mach number. + plane : str, optional + ``"pitch"`` (perturb ``alpha``, use the normal force and pitch moment) or + ``"yaw"`` (perturb ``beta``, use the side force and yaw moment). Default + ``"pitch"``. + step : float, optional + Half-step, in radians, of the central difference. Default ``1e-4``. + + Returns + ------- + tuple of float + ``(neutral_point, slope)``: the neutral-point axial position in the + user-defined rocket frame, and the force-curve slope ``dCN/da`` (pitch) + or ``dCY/db`` (yaw) at the state. When the slope vanishes (no lift at all) + the neutral point falls back to the zero-incidence aerodynamic center. + """ + rocket.evaluate_surfaces_cp_to_cdm() + reference_length = 2 * rocket.radius + dynamic_pressure_area = 0.5 * rocket.area # unit speed, unit density + dynamic_pressure_area_length = dynamic_pressure_area * reference_length + + def coefficients(a, b): + r1, r2, _, m1, m2, _ = summed_force_and_moment( + rocket, a, b, mach, (0.0, 0.0, 0.0) + ) + if plane == "yaw": + return r1 / dynamic_pressure_area, m2 / dynamic_pressure_area_length + return -r2 / dynamic_pressure_area, m1 / dynamic_pressure_area_length + + if plane == "yaw": + force_high, moment_high = coefficients(alpha, beta + step) + force_low, moment_low = coefficients(alpha, beta - step) + else: + force_high, moment_high = coefficients(alpha + step, beta) + force_low, moment_low = coefficients(alpha - step, beta) + + force_slope = (force_high - force_low) / (2 * step) + moment_slope = (moment_high - moment_low) / (2 * step) + if force_slope == 0: + center = ( + rocket.aerodynamic_center_yaw + if plane == "yaw" + else rocket.aerodynamic_center + ) + return center.get_value_opt(mach), 0.0 + neutral_point = rocket.center_of_dry_mass_position + ( + rocket._csys * reference_length * moment_slope / force_slope + ) + return neutral_point, force_slope + + +def full_body_coefficients(rocket, machs=None, force_convention="body"): + """Compute the rocket's lumped stability-derivative set, split by motor + phase. Backs :meth:`rocketpy.Rocket.to_coefficients`; see that method for the + full description of the returned coefficient sets and their limitations. + """ + if force_convention not in ("body", "wind"): + raise ValueError( + f"force_convention must be 'body' or 'wind', got {force_convention!r}." + ) + if machs is None: + machs = np.arange(0.0, 3.01, 0.02) + machs = np.asarray(machs, dtype=float) + # Make sure each surface's center-of-pressure offset is current. + rocket.evaluate_surfaces_cp_to_cdm() + + reference_length = 2 * rocket.radius + dynamic_pressure_area = 0.5 * rocket.area # unit speed, unit density + dynamic_pressure_area_length = dynamic_pressure_area * reference_length + + def coefficients_at(alpha, beta, red_pitch, red_yaw, red_roll, mach): + # reduced rate -> body rate at unit speed: omega = rate * 2 V / L_ref + rate_factor = 2.0 / reference_length + omega = ( + red_pitch * rate_factor, + red_yaw * rate_factor, + red_roll * rate_factor, + ) + r1, r2, _, m1, m2, m3 = summed_force_and_moment( + rocket, alpha, beta, mach, omega + ) + return { + "cN": -r2 / dynamic_pressure_area, + "cY": r1 / dynamic_pressure_area, + "cm": m1 / dynamic_pressure_area_length, + "cn": m2 / dynamic_pressure_area_length, + "cl": m3 / dynamic_pressure_area_length, + } + + step = 1e-5 + + def slope(field, coeff): + values = [] + for mach in machs: + state = { + "alpha": 0.0, + "beta": 0.0, + "red_pitch": 0.0, + "red_yaw": 0.0, + "red_roll": 0.0, + } + high = coefficients_at(mach=mach, **{**state, field: step}) + low = coefficients_at(mach=mach, **{**state, field: -step}) + values.append((high[coeff] - low[coeff]) / (2 * step)) + return np.array(values) + + # Motor-independent derivative values on the Mach grid, in the body frame. + # The rocket's shape does not change with the motor, so these are shared by + # both phases; only the drag below differs. + derivatives = { + "cN_alpha": slope("alpha", "cN"), + "cm_alpha": slope("alpha", "cm"), + "cN_q": slope("red_pitch", "cN"), + "cm_q": slope("red_pitch", "cm"), + "cY_beta": slope("beta", "cY"), + "cn_beta": slope("beta", "cn"), + "cY_r": slope("red_yaw", "cY"), + "cn_r": slope("red_yaw", "cn"), + "cl_p": slope("red_roll", "cl"), + } + # The drag is a Mach curve at zero incidence (the axial coefficient's + # constant term), and it is the one term that differs by motor phase. + drag_by_phase = { + "power_off": rocket.power_off_drag_by_mach, + "power_on": rocket.power_on_drag_by_mach, + } + + result = {} + for phase, drag_curve in drag_by_phase.items(): + values = { + **derivatives, + "cA_0": np.array([drag_curve.get_value_opt(m) for m in machs]), + } + if force_convention == "wind": + values = body_derivatives_to_wind(values) + result[phase] = { + coeff_name: Function( + np.column_stack([machs, curve]), + "Mach", + coeff_name, + interpolation="akima", + extrapolation="constant", + ) + for coeff_name, curve in values.items() + } + return result + + +def body_derivatives_to_wind(body): + """Express a body-frame derivative set (``cN_*``/``cY_*``/``cA_*``) in the + wind-frame names (``cL_*``/``cQ_*``/``cD_*``); the moment derivatives are + frame-shared. Two cross terms fold the axial force in at incidence, + ``cL_alpha = cN_alpha - cA_0`` and ``cQ_beta = cY_beta + cA_0`` -- the linear + inverse of the wind-to-body rotation :class:`LinearGenericSurface` applies to + a wind-frame input, so feeding the result back with + ``force_convention="wind"`` recovers the same body-frame surface. Operates on + the tabulated derivative values (arrays over the Mach grid). + """ + drag = body.get("cA_0", 0.0) + rename = {"cN": "cL", "cY": "cQ", "cA": "cD"} + wind = {} + for key, value in body.items(): + prefix, sep, suffix = key.partition("_") + wind[f"{rename.get(prefix, prefix)}{sep}{suffix}"] = value + if "cN_alpha" in body: + wind["cL_alpha"] = body["cN_alpha"] - drag + if "cY_beta" in body: + wind["cQ_beta"] = body["cY_beta"] + drag + return wind diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 821eb884d..32f32a231 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -14,6 +14,7 @@ from rocketpy.rocket.aero_surface import ( AirBrakes, EllipticalFins, + Fin, Fins, NoseCone, RailButtons, @@ -26,7 +27,13 @@ from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface from rocketpy.rocket.components import Components +from rocketpy.rocket.helpers import ( + full_body_coefficients, + neutral_point_and_slope, + zero_drag, +) from rocketpy.rocket.parachute import Parachute from rocketpy.tools import ( deprecated, @@ -35,22 +42,6 @@ ) -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. @@ -156,10 +147,12 @@ class Rocket: 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 - the center of pressure and the center of mass, divided by the - rocket's diameter. + Stability margin of the rocket, in calibers, as a function of angle of + attack (radians), mach number and time. Stability margin is defined as + the distance between the center of pressure and the center of mass, + divided by the rocket's diameter. The angle-of-attack argument matters + only when a surface is nonlinear in incidence (see + ``Rocket.is_incidence_linear``); otherwise it has no effect. Rocket.static_margin : Function Static margin of the rocket, in calibers, as a function of time. Static margin is defined as the distance between the center of pressure and the @@ -362,6 +355,9 @@ def __init__( # pylint: disable=too-many-statements self.sensors_by_name = {} self.aerodynamic_surfaces = Components() self.surfaces_cp_to_cdm = {} + # Set once a full-body model replaces the modeled aerodynamics + # (add_full_body_aerodynamics(overwrite=True)); warns on later surface adds. + self._aerodynamics_overwritten = False self.rail_buttons = Components() self._aerodynamic_center = Function( @@ -378,8 +374,8 @@ def __init__( # pylint: disable=too-many-statements lambda time: 0, inputs="Time (s)", outputs="Static Margin (c)" ) self._stability_margin = Function( - lambda mach, time: 0, - inputs=["Mach", "Time (s)"], + lambda alpha, mach, time: 0, + inputs=["Angle of Attack (rad)", "Mach", "Time (s)"], outputs="Stability Margin (c)", ) # Yaw-plane counterparts. The pitch-plane attributes above remain the @@ -399,8 +395,8 @@ def __init__( # pylint: disable=too-many-statements lambda time: 0, inputs="Time (s)", outputs="Static Margin - Yaw (c)" ) self._stability_margin_yaw = Function( - lambda mach, time: 0, - inputs=["Mach", "Time (s)"], + lambda beta, mach, time: 0, + inputs=["Sideslip Angle (rad)", "Mach", "Time (s)"], outputs="Stability Margin - Yaw (c)", ) @@ -460,6 +456,9 @@ def __init__( # pylint: disable=too-many-statements # The aerodynamic center and the margins are evaluated lazily self._cp_outdated = True self._margin_outdated = True + # Whether the neutral point moves with angle of attack; set when the + # margins are evaluated (see evaluate_stability_margin). + self._is_incidence_linear = True # Flag for rocket non-axisymmetric warning. Used to show warning once. self._axisymmetry_warned = False @@ -683,6 +682,61 @@ def aerodynamic_center_yaw(self): self._ensure_aerodynamic_center() return self._aerodynamic_center_yaw + def neutral_point(self, alpha, mach): + """Pitch-plane neutral point at a finite angle of attack, in meters. + + The neutral point is the point about which the aerodynamic pitching + moment does not change for a small change in angle of attack. It is the + angle-of-attack-aware generalization of the + :attr:`aerodynamic_center`: evaluated at ``alpha = 0`` the two are equal, + and for a rocket built only from the linear Barrowman surfaces the + neutral point does not move with angle of attack at all. + + It moves with angle of attack only when a surface's normal force is + nonlinear in the angle of attack, for example a Galejs body-lift term + (growing like ``sin**2(alpha)``) added as a + :class:`rocketpy.GenericSurface`. In that case the neutral point migrates + as the angle of attack changes, exactly the behavior OpenRocket models, + and the flight stability margin follows it. + + Parameters + ---------- + alpha : float + Angle of attack, in radians, to evaluate the neutral point at. + mach : float + Free-stream Mach number. + + Returns + ------- + float + Axial position of the pitch-plane neutral point in the user-defined + rocket coordinate system, in meters. + """ + return neutral_point_and_slope(self, alpha, 0.0, mach, "pitch")[0] + + def neutral_point_yaw(self, beta, mach): + """Yaw-plane neutral point at a finite sideslip angle, in meters. + + Yaw-plane counterpart of :meth:`neutral_point`: the point about which the + yaw moment does not change for a small change in sideslip angle, + evaluated at the given sideslip angle. Equal to + :attr:`aerodynamic_center_yaw` at ``beta = 0``. + + Parameters + ---------- + beta : float + Sideslip angle, in radians, to evaluate the neutral point at. + mach : float + Free-stream Mach number. + + Returns + ------- + float + Axial position of the yaw-plane neutral point in the user-defined + rocket coordinate system, in meters. + """ + return neutral_point_and_slope(self, 0.0, beta, mach, "yaw")[0] + @property def total_lift_coeff_der(self): """Total normal-force-coefficient derivative vs Mach (lazily evaluated).""" @@ -709,16 +763,79 @@ def static_margin_yaw(self): @property def stability_margin(self): - """Pitch-plane stability margin (calibers) vs Mach and time (lazy).""" + """Pitch-plane stability margin (calibers) as a function of angle of + attack (radians), Mach and time (lazily evaluated). The angle-of-attack + argument matters only for a rocket that is nonlinear in incidence (see + :attr:`is_incidence_linear`); otherwise it has no effect and the margin + reduces to the Mach-and-time value.""" self._ensure_margins() return self._stability_margin @property def stability_margin_yaw(self): - """Yaw-plane stability margin (calibers) vs Mach and time (lazy).""" + """Yaw-plane stability margin (calibers) as a function of sideslip angle + (radians), Mach and time (lazily evaluated). Equal to + :attr:`stability_margin` for an axisymmetric rocket.""" self._ensure_margins() return self._stability_margin_yaw + @property + def length(self): + """Overall aerodynamic length of the rocket, in meters. + + This is the axial distance from the fore-most point of the rocket (the + nose cone tip) to the aft-most point of the rocket. It is measured along + the rocket axis and does not depend on the chosen coordinate-system + orientation. + + The aft-most point is usually the trailing edge of the last fin set or + the base of the aft tail, but if the motor nozzle extends past the last + aerodynamic surface, the nozzle sets the aft end instead. The rocket + must have at least one aerodynamic surface with a defined axial extent + (a nose cone, tail or fin set); otherwise a ``ValueError`` is raised. + + This length is what the hobby-rocketry convention of expressing the + static/stability margin as a *percentage of body length* is measured + against, as opposed to the caliber (diameter) convention used by + ``static_margin`` and ``stability_margin``. + + Returns + ------- + float + Overall aerodynamic length of the rocket, in meters. + """ + fore_points = [] + aft_points = [] + for surface, position in self.aerodynamic_surfaces: + if isinstance(surface, (NoseCone, Tail)): + axial_extent = surface.length + elif isinstance(surface, (Fins, Fin)): + axial_extent = surface.root_chord + else: + # Generic/controllable surfaces have no defined axial extent; + # they contribute a single point at their reference position. + axial_extent = 0.0 + # The reference point and the point one axial extent toward the tail + # (the tail direction is -_csys along the z axis). Taking the global + # extremes makes the result independent of which end is the reference. + fore_points.append(position.z) + aft_points.append(position.z - self._csys * axial_extent) + + if not fore_points: + raise ValueError( + "The rocket must have at least one aerodynamic surface to have a " + "defined length." + ) + + all_points = fore_points + aft_points + # Include the nozzle if a real motor extends past the aerodynamic + # surfaces. nozzle_position is already in the rocket reference frame. + if getattr(self, "motor", None) is not None and not isinstance( + self.motor, EmptyMotor + ): + all_points.append(self.nozzle_position) + return max(all_points) - min(all_points) + def evaluate_center_of_pressure(self): """Evaluates the rocket's aerodynamic center (and cp_position) as a function of Mach number, relative to the user-defined rocket reference @@ -760,12 +877,12 @@ def evaluate_center_of_pressure(self): if len(self.aerodynamic_surfaces) > 0: for aero_surface, position in self.aerodynamic_surfaces: # Force-curve slopes as Functions of Mach, from the surface's - # coefficient derivatives evaluated at zero alpha/beta and zero + # coefficient derivatives sliced 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) + lift_coeff_der = aero_surface.cN_alpha.slice("mach") + side_coeff_der = -1.0 * aero_surface.cY_beta.slice("mach") 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 @@ -838,158 +955,24 @@ def is_axisymmetric(self): return self._cp_plane_max_difference() <= 1e-6 * (2 * self.radius) @property - def cp_position(self): - """Alias for :attr:`aerodynamic_center`. + def is_incidence_linear(self): + """``True`` when the rocket's aerodynamics are linear in the angle of + attack, so the neutral point (and therefore the stability margin) does + not move as the angle of attack changes. This holds for a rocket built + only from the linear Barrowman surfaces. It is ``False`` when a surface's + normal force is nonlinear in incidence, such as a Galejs body-lift term + added as a :class:`rocketpy.GenericSurface`, in which case + :attr:`stability_margin` varies with its angle-of-attack argument.""" + self._ensure_margins() + return self._is_incidence_linear - 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. - """ - # 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 + @property + def cp_position(self): + """Alias for :attr:`aerodynamic_center`. Traditional center of pressure + position, defined as the linearized (small-incidence) center of pressure, + is the same as the aerodynamic center.""" 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 - 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: - - - ``cN`` (normal force), ``cY`` (side force), ``cA`` (axial force); - - ``cm`` (pitch), ``cn`` (yaw), ``cl`` (roll). - - 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 - ---------- - 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 - ``{"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 ("cN", "cY", "cA", "cm", "cn", "cl")} - reference_length = 2 * self.radius - dynamic_pressure_area_length = dynamic_pressure_area * reference_length - # Body-frame force components: R1 = cY, R2 = -cN, R3 = -cA (see - # GenericSurface.compute_forces_and_moments). - return { - "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, - } - 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 @@ -1020,8 +1003,7 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): # 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). + # into the body frame by ``_rotation_surface_to_body`` application_point = getattr( surface, "force_application_point", @@ -1032,42 +1014,122 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): ) # TODO: this should be recomputed whenever cant angle changes for fin self.surfaces_cp_to_cdm[surface] = pos + def _evaluate_is_incidence_linear(self): + """Detect whether the rocket's aerodynamics are linear in the angle of + attack, i.e. whether the neutral point moves as the angle of attack + changes. Returns ``True`` for a rocket built only from the linear + Barrowman surfaces and ``False`` when a surface's normal force is + nonlinear in incidence (for example a Galejs body-lift term added as a + :class:`rocketpy.GenericSurface`). + + The check central-differences the neutral point at zero and at five + degrees of incidence, in both the pitch and yaw planes; if either plane + moves, the rocket is treated as incidence-nonlinear. + """ + probe_mach = 0.3 + probe_alpha = math.radians(5.0) + for plane in ("pitch", "yaw"): + if plane == "yaw": + point_zero = neutral_point_and_slope(self, 0.0, 0.0, probe_mach, "yaw") + point_five = neutral_point_and_slope( + self, 0.0, probe_alpha, probe_mach, "yaw" + ) + else: + point_zero = neutral_point_and_slope(self, 0.0, 0.0, probe_mach, "pitch") + point_five = neutral_point_and_slope( + self, probe_alpha, 0.0, probe_mach, "pitch" + ) + if abs(point_five[0] - point_zero[0]) > 1e-6: + return False + return True + + def _neutral_point_margin_slope(self, incidence, mach, time, plane="pitch"): + """Stability margin (in calibers) and local normal-force-curve slope at a + single flow state, the shared computation behind ``stability_margin`` and + the flight dynamic-stability oscillator. + + For a rocket that is linear in incidence the neutral point is the + zero-incidence :attr:`aerodynamic_center` and ``incidence`` is ignored, + keeping the fast analytic path (and byte-for-byte the previous margin + values). Otherwise the neutral point and slope are found at ``incidence`` + by :func:`rocketpy.rocket.helpers.neutral_point_and_slope`, so a surface + that is nonlinear in the angle of attack (e.g. a Galejs body-lift + :class:`rocketpy.GenericSurface`) makes the margin move with incidence. + + Parameters + ---------- + incidence : float + Angle of attack (pitch) or sideslip angle (yaw), in radians. + mach : float + Free-stream Mach number. + time : float + Flight time, in seconds, at which the center of mass is taken. + plane : str, optional + ``"pitch"`` or ``"yaw"``. Default ``"pitch"``. + + Returns + ------- + tuple of float + ``(margin, slope)``: the stability margin in calibers and the local + force-curve slope (``dCN/dalpha`` for pitch, ``dCY/dbeta`` for yaw). + """ + self._ensure_margins() + if plane == "yaw": + center = self.aerodynamic_center_yaw + slope_curve = self.total_side_coeff_der + else: + center = self.aerodynamic_center + slope_curve = self.total_lift_coeff_der + + if self._is_incidence_linear: + neutral_point = center.get_value_opt(mach) + slope = slope_curve.get_value_opt(mach) + elif plane == "yaw": + neutral_point, slope = neutral_point_and_slope( + self, 0.0, incidence, mach, "yaw" + ) + else: + neutral_point, slope = neutral_point_and_slope( + self, incidence, 0.0, mach, "pitch" + ) + + margin = ( + self._csys + * (self.center_of_mass.get_value_opt(time) - neutral_point) + / (2 * self.radius) + ) + return margin, slope + def evaluate_stability_margin(self): - """Calculates the stability margin of the rocket as a function of mach - number and time. + """Calculates the stability margin of the rocket as a function of angle + of attack, Mach number and time. Returns ------- 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 - the center of pressure and the center of mass, divided by the - rocket's diameter. + Stability margin of the rocket, in calibers, as a function of angle + of attack (radians), Mach number and time. The stability margin is + the distance between the center of pressure and the center of mass, + divided by the rocket's diameter. It depends on the angle of attack + only when a surface is nonlinear in incidence; for a rocket built + from the linear Barrowman surfaces the angle-of-attack argument has + no effect. """ + self._is_incidence_linear = self._evaluate_is_incidence_linear() self._stability_margin.set_source( - lambda mach, time: ( - ( - ( - self.center_of_mass.get_value_opt(time) - - self.aerodynamic_center.get_value_opt(mach) - ) - / (2 * self.radius) - ) - * self._csys - ) + lambda alpha, mach, time: self._neutral_point_margin_slope( + alpha, mach, time, "pitch" + )[0] ) + self._stability_margin.set_inputs(["Angle of Attack (rad)", "Mach", "Time (s)"]) # 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) - ) - * self._csys - ) + lambda beta, mach, time: self._neutral_point_margin_slope( + beta, mach, time, "yaw" + )[0] + ) + self._stability_margin_yaw.set_inputs( + ["Sideslip Angle (rad)", "Mach", "Time (s)"] ) return self._stability_margin @@ -1514,6 +1576,14 @@ def add_surfaces(self, surfaces, positions): ------- None """ + if self._aerodynamics_overwritten: + warnings.warn( + "This rocket's aerodynamics were overwritten by a full-body " + "model (add_full_body_aerodynamics(overwrite=True)); the surface(s) " + "you are adding now will be summed on top of that model.", + UserWarning, + stacklevel=2, + ) if isinstance(surfaces, Iterable): if isinstance(positions, Iterable): if len(surfaces) != len(positions): @@ -1536,68 +1606,242 @@ def add_surfaces(self, surfaces, positions): # 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" - ): - """Define the whole vehicle from a supplied set of aerodynamic - coefficients (a "rocket-as-:class:`GenericSurface`" model). + def add_full_body_aerodynamics(self, surfaces, position=None, overwrite=False): + """Add a prebuilt full-body aerodynamic surface: the whole rocket + modeled as a single surface. Instead of (or in addition to) modeling + each component, this lets you provide a set of coefficients for the + whole rocket, which is often easier. + + Parameters + ---------- + surfaces : GenericSurface or list of GenericSurface + The prebuilt full-body surface, or a list of them (for example a + power-on/power-off pair, each carrying its own ``active_during``). + Any of: + + - a :class:`GenericSurface`; + - a :class:`LinearGenericSurface`; + - a :class:`ControllableGenericSurface` for coefficients that + also depend on control-deflection axes. + + Reference the surface's coefficients to the rocket cross-section + area and diameter (build it with ``reference_area=rocket.area`` and + ``reference_length=2 * rocket.radius``) so it sums consistently with + the rest of the rocket. Because it is just another aerodynamic + surface, a full-body model can be **mixed** with modeled add-on + surfaces (e.g. use ``add_full_body_aerodynamics`` together with + ``add_tail``): they simply add. + + A rocket's aerodynamics usually differ between powered and coasting + flight. To capture this, build two surfaces, set each one's + ``active_during`` to ``"power_on"`` and ``"power_off"``, and pass + them together as a list; each then produces force only during its + phase. + position : int, float, optional + Position along the rocket's center axis (in the user coordinate + system) where the surface's resultant force is applied and about + which its moment coefficients are taken. Defaults to the center of + dry mass position. + overwrite : bool, optional + If ``True``, make this the rocket's only aerodynamics: every + aerodynamic surface already on the rocket is removed first, and both + built-in drag curves (``power_on_drag`` and ``power_off_drag``) are + cleared. Default ``False`` (the model is added on top of the + existing aerodynamics). + + Returns + ------- + GenericSurface or list of GenericSurface + The surface(s) added. + """ + if position is None: + position = self.center_of_dry_mass_position + + if overwrite: + self._clear_aerodynamic_surfaces() + + surface_list = ( + list(surfaces) if isinstance(surfaces, (list, tuple)) else [surfaces] + ) + for surface in surface_list: + self.add_surfaces(surface, position) + + if overwrite: + # Re-arm the "added after" guard now that the full-body model is set. + self._aerodynamics_overwritten = True + + return surfaces + + def _clear_aerodynamic_surfaces(self): + """Wipe the rocket's aerodynamics so a full-body model can fully replace + them: remove every aerodynamic surface, clear both built-in drag curves, + and reset the derived stability caches. Used by + :meth:`add_full_body_aerodynamics` with ``overwrite=True``. + """ + self.aerodynamic_surfaces.clear() + self.surfaces_cp_to_cdm.clear() + # Clear both built-in drag curves; the supplied surface(s) now provide + # the complete aerodynamics, including any drag they carry. + zero_drag(self, "power_on") + zero_drag(self, "power_off") + warnings.warn( + "add_full_body_aerodynamics(overwrite=True): the rocket's existing " + "aerodynamic surfaces and both built-in drag curves (power_on_drag, " + "power_off_drag) were cleared; the supplied surface(s) now provide " + "the complete aerodynamics, including any drag they carry.", + UserWarning, + stacklevel=3, + ) + self._cp_outdated = True + self._margin_outdated = True + self._axisymmetry_warned = False + # New adds are welcome again; the guard is re-armed once the full-body + # surfaces are in place (see add_full_body_aerodynamics). + self._aerodynamics_overwritten = False + + def to_coefficients(self, machs=None, force_convention="body"): + """Return the whole rocket's aerodynamic coefficients, split by motor + phase. + + Sweeps the rocket's aerodynamic surfaces and lumps them into the + rocket's complete stability-derivative set about the dry center of mass: + the normal-force and pitch-moment slopes ``cN_alpha``/``cm_alpha`` + (pitch), the side-force and yaw-moment slopes ``cY_beta``/``cn_beta`` + (yaw), the pitch and yaw rate damping ``cN_q``/``cm_q`` and + ``cY_r``/``cn_r``, the fin roll damping ``cl_p`` and the drag ``cA_0``. + + The result is returned as two coefficient sets, ``"power_off"`` + (coasting) and ``"power_on"`` (motor burning). + + Important + --------- + The resulting coefficients are a **linear summary tabulated only against + Mach**: the derivatives are taken at zero angle of attack, zero sideslip + and zero rates, so only their Mach dependence is kept. This leaves out: + + - **Incidence and rate nonlinearity.** Only the slope at zero is + retained, so any curvature in angle of attack, sideslip or the body + rates is not represented. + - **Reynolds dependence.** The derivatives are measured in the + vanishing-Reynolds limit, so a coefficient that varies with Reynolds + number is frozen at that value rather than following the flight + Reynolds number. + - **Control-surface dependence.** Deflection axes of a + :class:`ControllableGenericSurface` are not carried into the summary. + - **Induced drag.** The axial coefficient is constant in incidence, so + drag does not increase with angle of attack. + + These are exactly the assumptions of RocketPy's built-in Barrowman + surfaces (:class:`NoseCone`, :class:`Tail` and the fin sets), which are + already linear, Mach-tabulated and Reynolds-independent. A rocket built + only from them is therefore reproduced exactly, with nothing lost. The + limitations matter only when you have added a :class:`GenericSurface` or + :class:`ControllableGenericSurface` (or a Reynolds-dependent + :class:`LinearGenericSurface`) whose coefficients truly vary with + incidence beyond a straight line, with Reynolds number, or with a + control deflection. - 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. + Parameters + ---------- + machs : sequence of float, optional + Mach numbers at which the derivatives are sampled and tabulated. + Defaults to ``0`` to ``3`` in steps of ``0.02``. + force_convention : str, optional + The frame the force coefficients are named in. ``"body"`` (default) + gives the body-frame set : normal ``cN_*``, side ``cY_*`` and axial + ``cA_0`` (drag). ``"wind"`` gives the wind-frame set: lift + ``cL_*``, side ``cQ_*`` and drag ``cD_0``. The moment derivatives + (``cm_*``, ``cn_*``, ``cl_p``) are the same in both. - 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. + Returns + ------- + dict + A dict with keys ``"power_off"`` and ``"power_on"``. Each value is + itself a dict mapping a coefficient name to its curve over Mach (a + :class:`rocketpy.Function`). The body-frame set is ``cN_alpha``, + ``cm_alpha``, ``cN_q``, ``cm_q``, ``cY_beta``, ``cn_beta``, + ``cY_r``, ``cn_r``, ``cl_p`` and ``cA_0``. + """ + return full_body_coefficients(self, machs, force_convention) + + def to_surface( + self, + machs=None, + force_convention="body", + name="Full Body Aerodynamics", + ): + """Collapse the whole assembled rocket aerodynamics into two + :class:`rocketpy.LinearGenericSurface` objects, one for coasting and one + for powered flight. It reproduces the source rocket's aerodynamics, so a + bare rocket carrying the same body and motor plus this pair flies the + same as the fully modeled rocket. + + Important + --------- + The resulting coefficients are a **linear summary tabulated only against + Mach**: the derivatives are taken at zero angle of attack, zero sideslip + and zero rates, so only their Mach dependence is kept. This leaves out: + + - **Incidence and rate nonlinearity.** Only the slope at zero is + retained, so any curvature in angle of attack, sideslip or the body + rates is not represented. + - **Reynolds dependence.** The derivatives are measured in the + vanishing-Reynolds limit, so a coefficient that varies with Reynolds + number is frozen at that value rather than following the flight + Reynolds number. + - **Control-surface dependence.** Deflection axes of a + :class:`ControllableGenericSurface` are not carried into the summary. + - **Induced drag.** The axial coefficient is constant in incidence, so + drag does not increase with angle of attack. + + These are exactly the assumptions of RocketPy's built-in Barrowman + surfaces (:class:`NoseCone`, :class:`Tail` and the fin sets), which are + already linear, Mach-tabulated and Reynolds-independent. A rocket built + only from them is therefore reproduced exactly, with nothing lost. The + limitations matter only when you have added a :class:`GenericSurface` or + :class:`ControllableGenericSurface` (or a Reynolds-dependent + :class:`LinearGenericSurface`) whose coefficients truly vary with + incidence beyond a straight line, with Reynolds number, or with a + control deflection. 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. + machs : sequence of float, optional + Mach numbers at which the derivatives are sampled and tabulated. + Defaults to ``0`` to ``3`` in steps of ``0.02``. + force_convention : str, optional + The frame the force coefficients are expressed in. ``"body"`` + (default) gives the body-frame set: normal ``cN``, side ``cY`` and + axial ``cA`` (drag). ``"wind"`` gives the wind-frame set: lift + ``cL``, side ``cQ`` and drag ``cD``. The moment coefficients are the + same in both. name : str, optional - Name of the surface. Default ``"Vehicle Aerodynamics"``. + Base name of the returned surfaces. + Default ``"Full Body 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. + list of rocketpy.LinearGenericSurface + Two surfaces, ``[power_off, power_on]``, each carrying the whole + rocket's coefficient derivatives referenced to the rocket + cross-section area and diameter and taken about the center of dry + mass, and gated to its motor phase. """ - 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, + coefficients = self.to_coefficients( + machs=machs, + force_convention=force_convention, ) - self.add_surfaces(surface, reference_position) - return surface + return [ + LinearGenericSurface( + reference_area=self.area, + reference_length=2 * self.radius, + coefficients=coefficients[phase], + force_convention=force_convention, + name=f"{name} ({phase.replace('_', ' ')})", + active_during=phase, + ) + for phase in ("power_off", "power_on") + ] def _add_controllers(self, controllers): """Adds a controller to the rocket. @@ -1726,7 +1970,7 @@ def add_nose( @deprecated( reason="This method is set to be deprecated in version 1.0.0 and fully " - "removed by version 2.0.0", + "removed by version 1.14.0", alternative="Rocket.add_trapezoidal_fins", ) def add_fins(self, *args, **kwargs): # pragma: no cover @@ -2678,7 +2922,13 @@ def to_dict(self, **kwargs): if kwargs.get("include_outputs", False): thrust_to_weight = self.thrust_to_weight aerodynamic_center = self.aerodynamic_center - stability_margin = self.stability_margin + # The zero-incidence design surface (Mach, time), a 2-D slice of the + # angle-of-attack-aware stability_margin, for output inspection. + stability_margin = Function( + lambda mach, time: self.stability_margin.get_value_opt(0.0, mach, time), + inputs=["Mach", "Time (s)"], + outputs="Stability Margin (c)", + ) center_of_mass = self.center_of_mass motor_center_of_mass_position = self.motor_center_of_mass_position reduced_mass = self.reduced_mass diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index b5b8e7723..2ae53bc41 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -2304,48 +2304,77 @@ def attitude_frequency_response(self): @cached_property def static_margin(self): - """Static margin of the rocket.""" + """Static margin of the rocket (linear, zero-airspeed reference).""" return self.rocket.static_margin + def _incidence(self, time): + """Total angle of attack, in radians (unsigned), used as the incidence + for the stability quantities. This is exact for a crosswind in the pitch + plane and a close approximation for combined pitch-and-yaw incidence.""" + return abs(np.radians(self.angle_of_attack.get_value_opt(time))) + @funcify_method("Time (s)", "Stability Margin (c)", "linear", "zero") def stability_margin(self): - """Linear stability margin along the flight, in calibers. + """Pitch-plane stability margin along the flight, in calibers. + + This samples the rocket's own :attr:`Rocket.stability_margin` along the + flight's realized angle of attack, Mach number and time. It therefore + accounts for the center-of-mass shift as propellant burns, the variation + of the aerodynamic center with Mach number, and, when a surface is + nonlinear in incidence (e.g. a Galejs body-lift + :class:`rocketpy.GenericSurface`), the migration of the neutral point + with the flight angle of attack. In that last case the margin oscillates + as the angle of attack oscillates; for a rocket built only from the + linear Barrowman surfaces the angle of attack has no effect and this + reduces to the Mach-and-time margin. - 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. + For non-axisymmetric rockets, this represents the pitch plane. Returns ------- stability : rocketpy.Function Stability margin in calibers as a function of time. """ - return [(t, self.rocket.stability_margin(m, t)) for t, m in self.mach_number] + rocket_margin = self.rocket.stability_margin + margin = np.array( + [ + rocket_margin.get_value_opt( + self._incidence(t), self.mach_number.get_value_opt(t), t + ) + for t in self.time + ] + ) + return np.column_stack((self.time, margin)) @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 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. + Yaw-plane counterpart of :meth:`stability_margin`, sampling the rocket's + :attr:`Rocket.stability_margin_yaw` along the flight. 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 - ] + rocket_margin = self.rocket.stability_margin_yaw + margin = np.array( + [ + rocket_margin.get_value_opt( + self._incidence(t), self.mach_number.get_value_opt(t), t + ) + for t in self.time + ] + ) + return np.column_stack((self.time, margin)) - # Dynamic stability - # TODO: review note: the two methods below this comment needs to have its - # equations documented in a .rst file + # Dynamic stability. The equations behind the two methods below (the lateral + # inertia and the linearized oscillator) are documented in + # docs/technical/aerodynamics/center_of_pressure_and_stability.rst. 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 @@ -2370,23 +2399,27 @@ def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): ) return inertia - def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): + def _dynamic_stability(self, plane, 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`. + ``plane`` is ``"pitch"`` or ``"yaw"``; ``lateral_inertia`` is the array + from :meth:`_lateral_inertia`. The restoring moment ``C1`` is built from + the angle-of-attack-aware margin and force-curve slope taken from the + rocket (:meth:`Rocket._neutral_point_margin_slope`) at the flight angle + of attack, so for a rocket that is nonlinear in incidence the natural + frequency and damping ratio move with the angle of attack. The + aerodynamic damping ``C2`` uses each surface's zero-incidence slope (its + second-order incidence dependence is neglected). """ - 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 + rocket = self.rocket + area = rocket.area + diameter = 2 * rocket.radius + csys = rocket._csys + nozzle_position = rocket.nozzle_position + mass_flow_rate = rocket.motor.total_mass_flow_rate corrective = np.empty(len(self.time)) damping = np.empty(len(self.time)) @@ -2397,16 +2430,15 @@ def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): 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 + # Angle-of-attack-aware margin and local restoring-force slope, from + # the rocket, at this instant's incidence, Mach and time. + margin, force_slope = rocket._neutral_point_margin_slope( + self._incidence(t), mach, t, plane ) + # Corrective moment per radian: q A C_Nalpha (x_cm - x_np). + corrective[i] = dynamic_pressure * area * force_slope * 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: @@ -2429,6 +2461,15 @@ def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): damping[i] = damping_aero + damping_jet positive_corrective = np.clip(corrective, 0.0, None) + # The damping ratio C2 / (2 sqrt(C1 I)) is ill-conditioned when the + # corrective moment C1 is ~0, i.e. at very low airspeed (on the launch + # rail): the propulsive (jet) part of C2 is already at full strength + # while C1 -> 0, so the ratio blows up even though there is no real + # oscillation yet. Only report it where C1 is a meaningful fraction of + # its flight maximum; elsewhere it is left at 0. The natural frequency + # sqrt(C1 / I) has C1 in the numerator, so it stays well-behaved. + max_corrective = positive_corrective.max(initial=0.0) + meaningful = positive_corrective > 1e-4 * max_corrective with np.errstate(divide="ignore", invalid="ignore"): natural_frequency = np.sqrt(positive_corrective / lateral_inertia) denominator = 2.0 * np.sqrt(positive_corrective * lateral_inertia) @@ -2436,7 +2477,7 @@ def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): damping, denominator, out=np.zeros_like(damping), - where=denominator > 0, + where=meaningful, ) return corrective, damping, natural_frequency, damping_ratio @@ -2446,9 +2487,7 @@ def corrective_moment_coefficient(self): 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 - ) + corrective, _, _, _ = self._dynamic_stability("pitch", inertia) return np.column_stack((self.time, corrective)) @funcify_method("Time (s)", "Damping Moment Coefficient (N m s/rad)", "linear") @@ -2457,9 +2496,7 @@ def damping_moment_coefficient(self): 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 - ) + _, damping, _, _ = self._dynamic_stability("pitch", inertia) return np.column_stack((self.time, damping)) @funcify_method("Time (s)", "Pitch Natural Frequency (rad/s)", "linear") @@ -2467,9 +2504,7 @@ 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 - ) + _, _, natural_frequency, _ = self._dynamic_stability("pitch", inertia) return np.column_stack((self.time, natural_frequency)) @funcify_method("Time (s)", "Pitch Damping Ratio", "linear") @@ -2478,9 +2513,7 @@ def pitch_damping_ratio(self): ``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 - ) + _, _, _, damping_ratio = self._dynamic_stability("pitch", inertia) return np.column_stack((self.time, damping_ratio)) @funcify_method("Time (s)", "Yaw Natural Frequency (rad/s)", "linear") @@ -2489,11 +2522,7 @@ def yaw_natural_frequency(self): 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, - ) + _, _, natural_frequency, _ = self._dynamic_stability("yaw", inertia) return np.column_stack((self.time, natural_frequency)) @funcify_method("Time (s)", "Yaw Damping Ratio", "linear") @@ -2501,11 +2530,7 @@ 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, - ) + _, _, _, damping_ratio = self._dynamic_stability("yaw", inertia) return np.column_stack((self.time, damping_ratio)) # Rail Button Forces diff --git a/rocketpy/simulation/helpers/flight_derivatives.py b/rocketpy/simulation/helpers/flight_derivatives.py index dcd275076..42a4165d4 100644 --- a/rocketpy/simulation/helpers/flight_derivatives.py +++ b/rocketpy/simulation/helpers/flight_derivatives.py @@ -377,6 +377,8 @@ def u_dot(flight, t, u, post_processing=False): velocity_in_body_frame = Vector([vx_b, vy_b, vz_b]) w = Vector([omega1, omega2, omega3]) for aero_surface, _ in flight.rocket.aerodynamic_surfaces: + if not aero_surface.is_active(t, flight): + continue # Component cp relative to CDM in body frame comp_cp = flight.rocket.surfaces_cp_to_cdm[aero_surface] # Component absolute velocity in body frame @@ -614,6 +616,8 @@ def u_dot_generalized_3dof(flight, t, u, post_processing=False): vb_body = Kt @ v for surface, _ in flight.rocket.aerodynamic_surfaces: + if not surface.is_active(t, flight): + continue cp = flight.rocket.surfaces_cp_to_cdm[surface] vb_component = vb_body + (w ^ cp) @@ -858,6 +862,8 @@ def u_dot_generalized(flight, t, u, post_processing=False): velocity_in_body_frame = Kt @ v # Calculate lift and moment for each component of the rocket for aero_surface, _ in flight.rocket.aerodynamic_surfaces: + if not aero_surface.is_active(t, flight): + continue # Component cp relative to CDM in body frame comp_cp = flight.rocket.surfaces_cp_to_cdm[aero_surface] # Component absolute velocity in body frame diff --git a/tests/fixtures/rockets/rocket_fixtures.py b/tests/fixtures/rockets/rocket_fixtures.py index 6ceabf589..ac2e6c418 100644 --- a/tests/fixtures/rockets/rocket_fixtures.py +++ b/tests/fixtures/rockets/rocket_fixtures.py @@ -1,10 +1,14 @@ +import math + import numpy as np import pytest -from rocketpy import LinearGenericSurface, Rocket +from rocketpy import Function, GenericSurface, LinearGenericSurface, Rocket +from rocketpy.mathutils.vector_matrix import Vector # 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. @@ -56,6 +60,201 @@ def _linear_surface_from_barrowman(surface): return linear_surface +def _generic_surface_from_barrowman(surface): + """Build a :class:`GenericSurface` that reproduces a Barrowman surface's aero. + + Same idea as :func:`_linear_surface_from_barrowman`, but expressed as a plain + :class:`GenericSurface`: instead of coefficient *slopes*, the total body-frame + coefficients are given directly as the linear expansion of the Barrowman + curves. The normal force is ``cN = clalpha(mach) * alpha``, the side force is + ``cY = -clalpha(mach) * beta`` and (for fins) the roll moment is + ``cl = cl_0(mach) + cl_p(mach) * roll_rate``. Because these are the same + functions the linear surface builds internally, the two surfaces produce + identical forces and moments; this fixture exercises the plain generic-surface + code path. + + The surface applies its force at its own origin (center of pressure + ``(0, 0, 0)``); the source surface's ``cpz`` is exposed as ``barrowman_cpz`` + so the caller can add it at the Barrowman center-of-pressure station, exactly + as for the linear surface. + + Parameters + ---------- + surface : rocketpy.NoseCone, rocketpy.Tail or rocketpy fin set + A standard Barrowman aerodynamic surface to copy the aero curves from. + + Returns + ------- + rocketpy.GenericSurface + A 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 + + # Coefficient callables must accept the full 7-variable argument tuple. The + # slope ``clalpha`` is a Function of Mach only; the fin roll coefficients + # ``cl_0``/``cl_p`` are AeroCoefficients evaluated over the full tuple. + def make_normal(slope): + def cN(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + return slope.get_value_opt(mach) * alpha + + return cN + + def make_side(slope): + def cY(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + return -slope.get_value_opt(mach) * beta + + return cY + + coefficients = {"cN": make_normal(clalpha), "cY": make_side(clalpha)} + if getattr(surface, "roll_parameters", None) is not None: + cl_0, cl_p = surface.cl_0, surface.cl_p + + def make_roll(forcing, damping): + def cl(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) + cant = forcing.get_value_opt(*args) + return cant + damping.get_value_opt(*args) * roll_rate + + return cl + + coefficients["cl"] = make_roll(cl_0, cl_p) + + generic_surface = GenericSurface( + reference_area=surface.reference_area, + reference_length=surface.reference_length, + coefficients=coefficients, + center_of_pressure=(0, 0, 0), + name=f"{surface.name}_generic", + ) + generic_surface.barrowman_cpz = surface.cpz + return generic_surface + + +# The three Barrowman surfaces of the Calisto rocket and the axial stations +# (in the tail_to_nose user coordinate system) at which their origins sit. +_CALISTO_SURFACE_STATIONS = ( + ("nose", 1.160), + ("tail", -1.313), + ("fins", -1.168), +) + + +def _full_body_force_and_moment(rocket, alpha, beta, mach, omega, speed=1.0): + """Total body-frame force and moment about the dry center of mass, summed + over every aerodynamic surface at a flow state and set of body rates. + + Reproduces the flight integrator's per-surface computation: each surface is + fed its own local stream velocity, which includes the ``omega ^ cp`` + lever-arm term, so the sum captures the pitch/yaw damping the distributed + surfaces produce. Evaluated at unit air density; the result scales out of any + coefficient ratio. Used to lump the distributed surfaces into a single + full-body coefficient set. + """ + stream_direction = Vector([-math.tan(beta), -math.tan(alpha), -1.0]) + stream_at_cdm = stream_direction / abs(stream_direction) * speed + body_rates = Vector(list(omega)) + density = Function(1.0) + dynamic_viscosity = Function(1e30) # vanishing-Reynolds limit + speed_of_sound = speed / mach if mach > 0 else 1e30 + totals = np.zeros(6) + for surface, _ in rocket.aerodynamic_surfaces: + cp = rocket.surfaces_cp_to_cdm[surface] + comp_stream = stream_at_cdm - (body_rates ^ cp) + comp_speed = abs(comp_stream) + forces = surface.compute_forces_and_moments( + comp_stream, + comp_speed, + comp_speed / speed_of_sound, + 1.0, + cp, + body_rates, + density, + dynamic_viscosity, + 0.0, + ) + totals += np.array(forces) + return totals + + +def _full_body_derivatives(reference_rocket): + """Lump a rocket's distributed aerodynamic surfaces into one full-body + stability-derivative set (about the dry center of mass), as named slopes for + a :class:`LinearGenericSurface`. + + A single surface placed at the center of mass has no lever arm of its own, so + the pitch/yaw damping the distributed fore-and-aft surfaces produce has to be + carried explicitly by rate derivatives. Every derivative is measured by + finite-differencing the distributed aerodynamics + (:func:`_full_body_force_and_moment`) and tabulated against Mach: the static + slopes ``cN_alpha``/``cm_alpha`` (pitch) and ``cY_beta``/``cn_beta`` (yaw), + the rate-damping slopes ``cN_q``/``cm_q`` and ``cY_r``/``cn_r``, and the fin + roll damping ``cl_p``. Axial (drag) is left out so the rocket's own drag curve + still applies. Handed to a linear surface, these reproduce the reference + rocket's aerodynamics as a lumped model -- the way a measured or CFD + stability-derivative set is used. + """ + reference_length = 2 * reference_rocket.radius + area = reference_rocket.area + machs = np.arange(0.0, 3.01, 0.02) + step = 1e-5 + dyn_area = 0.5 * area # unit speed, unit density + dyn_area_length = dyn_area * reference_length + + def coefficients_at(alpha, beta, red_pitch, red_yaw, red_roll, mach): + rate_factor = 2.0 / reference_length # reduced rate -> omega at unit speed + omega = (red_pitch * rate_factor, red_yaw * rate_factor, red_roll * rate_factor) + r1, r2, r3, m1, m2, m3 = _full_body_force_and_moment( + reference_rocket, alpha, beta, mach, omega + ) + return { + "cN": -r2 / dyn_area, + "cY": r1 / dyn_area, + "cm": m1 / dyn_area_length, + "cn": m2 / dyn_area_length, + "cl": m3 / dyn_area_length, + } + + def slope(field, coeff): + values = [] + for mach in machs: + state = { + "alpha": 0.0, + "beta": 0.0, + "red_pitch": 0.0, + "red_yaw": 0.0, + "red_roll": 0.0, + } + high = dict(state, **{field: step}) + low = dict(state, **{field: -step}) + c_high = coefficients_at(mach=mach, **high) + c_low = coefficients_at(mach=mach, **low) + values.append((c_high[coeff] - c_low[coeff]) / (2 * step)) + return Function( + np.column_stack([machs, values]), + "Mach", + f"{coeff}_slope", + interpolation="akima", + extrapolation="constant", + ) + + # Named slopes consumed directly by LinearGenericSurface: the rate names + # ``cN_q``/``cm_q`` multiply the reduced pitch rate, ``cY_r``/``cn_r`` the + # reduced yaw rate and ``cl_p`` the reduced roll rate. + return { + "cN_alpha": slope("alpha", "cN"), + "cm_alpha": slope("alpha", "cm"), + "cN_q": slope("red_pitch", "cN"), + "cm_q": slope("red_pitch", "cm"), + "cY_beta": slope("beta", "cY"), + "cn_beta": slope("beta", "cn"), + "cY_r": slope("red_yaw", "cY"), + "cn_r": slope("red_yaw", "cn"), + "cl_p": slope("red_roll", "cl"), + } + + @pytest.fixture def calisto_motorless(): """Create a simple object of the Rocket class to be used in the tests. This @@ -319,6 +518,162 @@ def calisto_linear_generic( return calisto +def _bare_calisto(motor): + """The Calisto body and motor with no aerodynamic surfaces, parachutes or + rail buttons yet -- the shared starting point for the generic-surface + Calistos below.""" + 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(motor, position=-1.373) + return calisto + + +@pytest.fixture +def calisto_generic( + cesaroni_m1670, + calisto_nose_cone, + calisto_tail, + calisto_trapezoidal_fins, + calisto_main_chute, + calisto_drogue_chute, +): + """Calisto built entirely from :class:`GenericSurface` objects. + + The exact counterpart of ``calisto_linear_generic``, but each surface is a + plain :class:`GenericSurface` carrying the *total* body-frame coefficients + (``cN = clalpha * alpha`` ...) instead of a :class:`LinearGenericSurface` + carrying the coefficient slopes. The two express the same aerodynamics + through different code paths, so this rocket's flight should match both + ``calisto_robust`` and ``calisto_linear_generic``. It is standalone (it does + not reuse the shared ``calisto`` fixture), so a test may build it alongside + the others 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 + GenericSurfaces. + """ + calisto = _bare_calisto(cesaroni_m1670) + barrowman_surfaces = { + "nose": calisto_nose_cone, + "tail": calisto_tail, + "fins": calisto_trapezoidal_fins, + } + for label, station in _CALISTO_SURFACE_STATIONS: + generic_surface = _generic_surface_from_barrowman(barrowman_surfaces[label]) + calisto.add_surfaces(generic_surface, station - generic_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_full_aerodynamics( + cesaroni_m1670, + calisto_nose_cone, + calisto_tail, + calisto_trapezoidal_fins, + calisto_main_chute, + calisto_drogue_chute, +): + """Calisto flown from a single full-body stability-derivative set. + + Instead of modeling each surface, this rocket carries one + :class:`LinearGenericSurface` added through + :meth:`Rocket.add_full_body_aerodynamics` that lumps the whole rocket into a + set of named coefficient slopes referenced to the dry center of mass. The + slopes are extracted from an equivalent ``calisto_generic`` rocket + (:func:`_full_body_derivatives`): the static normal-force and + pitch/yaw-moment slopes plus the pitch/yaw rate damping the distributed + surfaces produce through their lever arms and the fin roll damping. The + built-in drag curve is kept (no drag coefficient is supplied), exactly as for + the modeled Calisto, so this rocket's flight should match ``calisto_robust``, + ``calisto_linear_generic`` and ``calisto_generic``. + + 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 lumped in. This is a pytest + fixture too. + calisto_tail : rocketpy.Tail + The standard boat tail whose aero curves are lumped in. This is a pytest + fixture too. + calisto_trapezoidal_fins : rocketpy.TrapezoidalFins + The standard fin set whose aero curves are lumped in. 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 flown from a single full-body derivative set. + """ + # Distributed reference rocket (same motor, so the same dry center of mass) + # whose lumped derivatives feed the single full-body surface. + reference = _bare_calisto(cesaroni_m1670) + barrowman_surfaces = { + "nose": calisto_nose_cone, + "tail": calisto_tail, + "fins": calisto_trapezoidal_fins, + } + for label, station in _CALISTO_SURFACE_STATIONS: + generic_surface = _generic_surface_from_barrowman(barrowman_surfaces[label]) + reference.add_surfaces(generic_surface, station - generic_surface.barrowman_cpz) + + calisto = _bare_calisto(cesaroni_m1670) + full_body_surface = LinearGenericSurface( + reference_area=calisto.area, + reference_length=2 * calisto.radius, + coefficients=_full_body_derivatives(reference), + name="Calisto full body aerodynamics", + ) + calisto.add_full_body_aerodynamics(full_body_surface) + 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/rocket/aero_surface/test_aero_coefficient.py b/tests/unit/rocket/aero_surface/test_aero_coefficient.py index ed568773a..e795c02ac 100644 --- a/tests/unit/rocket/aero_surface/test_aero_coefficient.py +++ b/tests/unit/rocket/aero_surface/test_aero_coefficient.py @@ -72,25 +72,14 @@ def test_repr_constant_and_function(): assert "depends_on" in function_repr and "mach" in function_repr -# -- Independent-variable axes (unsteady / control) --------------------------- +# -- Independent-variable axes (control) -------------------------------------- -def test_build_independent_vars_base_unsteady_and_controls(): +def test_build_independent_vars_base_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, @@ -201,12 +190,10 @@ 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 diff --git a/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py index 837803648..8e893b14c 100644 --- a/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py +++ b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py @@ -99,3 +99,42 @@ def test_plain_generic_surface_default_independent_vars_unchanged(): "yaw_rate", "roll_rate", ] + + +def test_active_during_preset_round_trips_through_dict(): + """A preset activation policy survives to_dict/from_dict (jet-vane case).""" + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={}, + active_during="power_on", + ) + restored = ControllableGenericSurface.from_dict(surface.to_dict()) + assert restored.active_during == "power_on" + + +def test_active_during_callable_round_trips_through_dict(): + """A custom activation function is pickled through to_dict/from_dict and + restored to a working callable.""" + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={}, + active_during=lambda t, flight: t < 1.0, + ) + restored = ControllableGenericSurface.from_dict(surface.to_dict()) + assert callable(restored.active_during) + assert restored.active_during(0.5, None) is True + assert restored.active_during(2.0, None) is False + + +def test_active_during_callable_dropped_when_pickling_disabled(): + """With allow_pickle=False a custom function cannot be stored, so it saves + as the 'always' preset rather than a broken reference.""" + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={}, + active_during=lambda t, flight: t < 1.0, + ) + assert surface.to_dict(allow_pickle=False)["active_during"] == "always" diff --git a/tests/unit/rocket/aero_surface/test_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_generic_surfaces.py index 86e2269a6..5bbe81395 100644 --- a/tests/unit/rocket/aero_surface/test_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_generic_surfaces.py @@ -1,8 +1,18 @@ +import json +from types import SimpleNamespace + import pytest -from rocketpy import Function, GenericSurface +from rocketpy import Function, GenericSurface, LinearGenericSurface +from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder from rocketpy.mathutils import Vector + +def _rpy_round_trip(obj): + """Encode ``obj`` and decode it back through the .rpy encoder/decoder.""" + return json.loads(json.dumps(obj, cls=RocketPyEncoder), cls=RocketPyDecoder) + + REFERENCE_AREA = 1 REFERENCE_LENGTH = 1 @@ -265,3 +275,200 @@ def test_angular_rates_are_non_dimensionalized(): 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) + + +class _ExplodingAtmosphere: + """Stand-in for density/viscosity whose lookup raises, so a test can assert + the Reynolds computation (and thus the lookup) is skipped.""" + + def get_value_opt(self, z): + raise AssertionError("atmosphere lookup should have been skipped") + + +def test_reynolds_length_defaults_to_reference_length(): + gs = GenericSurface(REFERENCE_AREA, 0.2, {"cN": 0}) + assert gs.reynolds_length == 0.2 + + +def test_reynolds_length_override(): + gs = GenericSurface(REFERENCE_AREA, 0.2, {"cN": 0}, reynolds_length=4.0) + assert gs.reynolds_length == 4.0 + # The moment/rate reference length is left untouched. + assert gs.reference_length == 0.2 + + +def test_needs_reynolds_reflects_coefficient_dependence(): + without = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": lambda mach: mach} + ) + assert without._needs_reynolds is False + + with_re = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": lambda reynolds: reynolds} + ) + assert with_re._needs_reynolds is True + + +def test_reynolds_computation_skipped_when_no_coefficient_uses_it(): + """A surface with no Reynolds-dependent coefficient must not perform the + per-step atmosphere lookups (the exploding stand-ins would raise if it did).""" + gs = GenericSurface(REFERENCE_AREA, REFERENCE_LENGTH, {"cN": lambda mach: mach}) + gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -100)), + stream_speed=100, + stream_mach=0.3, + rho=1.0, + cp=Vector((0, 0, 0)), + omega=(0, 0, 0), + density=_ExplodingAtmosphere(), + dynamic_viscosity=_ExplodingAtmosphere(), + z=0, + ) + + +def test_reynolds_uses_reynolds_length_not_reference_length(): + """The Reynolds number handed to the coefficients is built on + ``reynolds_length``, not the (diameter) reference length.""" + ref_area, ref_length, re_length = 1.0, 0.2, 4.0 + rho_atm, mu, speed, rho = 1.2, 2.0e-5, 100.0, 1.0 + # cN returns the Reynolds number it is given, so the normal force exposes it. + gs = GenericSurface( + ref_area, + ref_length, + {"cN": lambda reynolds: reynolds}, + reynolds_length=re_length, + ) + + _, r2, *_ = gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0.3, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, 0), + density=Function(rho_atm), + dynamic_viscosity=Function(mu), + z=0, + ) + + # R2 = -normal = -(0.5 rho V^2 A_ref) * Re_seen + reynolds_seen = -r2 / (0.5 * rho * speed**2 * ref_area) + assert reynolds_seen == pytest.approx(rho_atm * speed * re_length / mu) + # ... which differs from the diameter-based value. + assert reynolds_seen != pytest.approx(rho_atm * speed * ref_length / mu) + + +def _fake_flight(burn_out_time): + """Minimal stand-in exposing only what ``is_active`` reads + (``flight.rocket.motor.burn_out_time``), so no real Flight is built.""" + return SimpleNamespace( + rocket=SimpleNamespace(motor=SimpleNamespace(burn_out_time=burn_out_time)) + ) + + +def test_active_during_defaults_to_always(): + """By default a surface is active at every time.""" + gs = GenericSurface(REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 1}) + flight = _fake_flight(burn_out_time=3.0) + assert gs.active_during == "always" + assert gs.is_active(0.0, flight) is True + assert gs.is_active(5.0, flight) is True + + +def test_active_during_power_on_gates_at_burnout(): + """A power-on surface is active up to (not including) burnout.""" + gs = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 1}, active_during="power_on" + ) + flight = _fake_flight(burn_out_time=3.0) + assert gs.is_active(2.999, flight) is True + assert gs.is_active(3.0, flight) is False + assert gs.is_active(4.0, flight) is False + + +def test_active_during_power_off_gates_at_burnout(): + """A power-off surface is active only from burnout onward.""" + gs = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 1}, active_during="power_off" + ) + flight = _fake_flight(burn_out_time=3.0) + assert gs.is_active(2.999, flight) is False + assert gs.is_active(3.0, flight) is True + assert gs.is_active(4.0, flight) is True + + +def test_active_during_accepts_callable(): + """A custom predicate receives (t, flight) and drives activation.""" + seen = [] + + def only_after_one_second(t, flight): + seen.append((t, flight)) + return t > 1.0 + + gs = GenericSurface( + REFERENCE_AREA, + REFERENCE_LENGTH, + {"cN": 1}, + active_during=only_after_one_second, + ) + flight = _fake_flight(burn_out_time=3.0) + assert gs.is_active(0.5, flight) is False + assert gs.is_active(2.0, flight) is True + # The predicate was called with the time and the flight object. + assert seen[0] == (0.5, flight) + + +def test_active_during_invalid_value_raises(): + """An unknown activation policy is rejected at construction.""" + with pytest.raises(ValueError, match="active_during"): + GenericSurface( + REFERENCE_AREA, + REFERENCE_LENGTH, + {"cN": 1}, + active_during="sometimes", + ) + + +def test_generic_surface_round_trips_through_encoder(): + """A GenericSurface survives the full .rpy encode/decode: coefficients, + reynolds_length and a custom activation function are all restored.""" + gs = GenericSurface( + reference_area=1.0, + reference_length=0.2, + coefficients={"cN": lambda mach: 2 * mach, "cm": 0.1}, + reynolds_length=4.0, + active_during=lambda t, flight: t < 3.0, + ) + restored = _rpy_round_trip(gs) + + assert isinstance(restored, GenericSurface) + assert restored.reynolds_length == 4.0 + assert restored.cN(0, 0, 0.5, 0, 0, 0, 0) == pytest.approx(1.0) + assert restored.cm(0, 0, 0, 0, 0, 0, 0) == pytest.approx(0.1) + assert restored.active_during(1.0, None) is True + assert restored.active_during(5.0, None) is False + + +def test_linear_generic_surface_round_trips_through_encoder(): + """A LinearGenericSurface restores its derivative coefficients and the + Reynolds length through the .rpy encode/decode.""" + lgs = LinearGenericSurface( + reference_area=1.0, + reference_length=0.2, + coefficients={"cN_alpha": 2.0, "cm_alpha": -0.5}, + reynolds_length=3.0, + ) + restored = _rpy_round_trip(lgs) + + assert isinstance(restored, LinearGenericSurface) + assert restored.reynolds_length == 3.0 + assert restored.cN_alpha(0, 0, 0, 0, 0, 0, 0) == pytest.approx(2.0) + assert restored.cm_alpha(0, 0, 0, 0, 0, 0, 0) == pytest.approx(-0.5) + + +def test_generic_surface_preset_active_during_round_trips(): + """A preset activation policy round-trips as the plain string.""" + gs = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 0}, active_during="power_on" + ) + assert _rpy_round_trip(gs).active_during == "power_on" diff --git a/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py b/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py index 674e583b7..a0c413080 100644 --- a/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py +++ b/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py @@ -106,7 +106,7 @@ def _surface_params(): 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.""" + 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)) diff --git a/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py b/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py deleted file mode 100644 index 893f90faf..000000000 --- a/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py +++ /dev/null @@ -1,71 +0,0 @@ -"""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_generic_calisto_equivalence.py b/tests/unit/rocket/test_generic_calisto_equivalence.py new file mode 100644 index 000000000..0882a5111 --- /dev/null +++ b/tests/unit/rocket/test_generic_calisto_equivalence.py @@ -0,0 +1,69 @@ +"""Non-Flight equivalence checks for the generic-surface Calisto rockets. + +``calisto_robust`` (Barrowman surfaces), ``calisto_linear_generic`` (per-surface +LinearGenericSurface), ``calisto_generic`` (per-surface GenericSurface) and +``calisto_full_aerodynamics`` (a single lumped full-body GenericSurface) all +describe the same Calisto aerodynamics. These tests pin that equivalence at the +coefficient/stability level -- without running a Flight -- so a regression in any +of the generic-surface code paths is caught cheaply. The full 6-DOF flight +comparison lives in ``tests/unit/simulation/test_flight.py``. +""" + +import numpy as np +import pytest + +GENERIC_FIXTURES = [ + "calisto_linear_generic", + "calisto_generic", + "calisto_full_aerodynamics", +] + + +@pytest.mark.parametrize("generic_name", GENERIC_FIXTURES) +def test_generic_calisto_matches_static_margin(request, calisto_robust, generic_name): + """Each generic-surface Calisto reproduces the Barrowman rocket's static + margin and Mach-dependent aerodynamic center.""" + generic = request.getfixturevalue(generic_name) + assert generic.static_margin(0) == pytest.approx( + calisto_robust.static_margin(0), rel=1e-3 + ) + for mach in (0.0, 0.3, 0.8, 1.2, 2.0): + assert generic.aerodynamic_center.get_value_opt(mach) == pytest.approx( + calisto_robust.aerodynamic_center.get_value_opt(mach), abs=1e-3 + ) + + +@pytest.mark.parametrize("generic_name", GENERIC_FIXTURES) +def test_generic_calisto_matches_aggregate_slopes( + request, calisto_robust, generic_name +): + """Each generic-surface Calisto reproduces the Barrowman rocket's total + normal-force and side-force curve slopes (the aggregate ``cN_alpha`` and + ``cY_beta`` the whole rocket presents) across a range of Mach numbers.""" + generic = request.getfixturevalue(generic_name) + for mach in (0.2, 0.6, 0.9, 1.5, 2.5): + assert generic.total_lift_coeff_der.get_value_opt(mach) == pytest.approx( + calisto_robust.total_lift_coeff_der.get_value_opt(mach), rel=2e-3 + ) + assert generic.total_side_coeff_der.get_value_opt(mach) == pytest.approx( + calisto_robust.total_side_coeff_der.get_value_opt(mach), rel=2e-3 + ) + + +def test_generic_and_linear_calisto_are_identical( + calisto_generic, calisto_linear_generic +): + """The per-surface GenericSurface and LinearGenericSurface Calistos express + the same coefficients through different code paths, so their aggregate + force-curve slopes and aerodynamic centers must agree to numerical + precision.""" + for mach in (0.2, 0.7, 1.3, 2.5): + for attr in ( + "total_lift_coeff_der", + "total_side_coeff_der", + "aerodynamic_center", + "aerodynamic_center_yaw", + ): + generic = getattr(calisto_generic, attr).get_value_opt(mach) + linear = getattr(calisto_linear_generic, attr).get_value_opt(mach) + assert generic == pytest.approx(linear, rel=1e-9, abs=1e-12) diff --git a/tests/unit/rocket/test_stability_rework.py b/tests/unit/rocket/test_stability_rework.py index b2995a514..8c27b07d1 100644 --- a/tests/unit/rocket/test_stability_rework.py +++ b/tests/unit/rocket/test_stability_rework.py @@ -5,6 +5,20 @@ import numpy as np import pytest +from rocketpy import Function, GenericSurface, LinearGenericSurface, Rocket + + +def _full_body_surface(rocket, coefficients, name="Full Body Aerodynamics", **kwargs): + """Build a full-body GenericSurface referenced to the rocket dimensions, + the way a user would before handing it to ``add_full_body_aerodynamics``.""" + return GenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients=coefficients, + name=name, + **kwargs, + ) + def test_cp_position_alias_matches_aerodynamic_center(calisto_robust): """``cp_position`` is a plain alias of ``aerodynamic_center`` (no warning).""" @@ -14,36 +28,45 @@ def test_cp_position_alias_matches_aerodynamic_center(calisto_robust): ) -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.)""" +def test_length_spans_nose_tip_to_aft_surface(calisto_robust): + """The overall length runs from the nose tip to the aft-most surface, and + does not depend on the coordinate-system orientation.""" 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 + # Nose tip at z = 1.160; tail base at z = -1.313 - 0.060 = -1.373. + assert rocket.length == pytest.approx(1.160 - (-1.373), abs=1e-9) + - coeffs = rocket.aerodynamic_coefficients_full(np.radians(0.1), 0.0, mach) - reconstructed_cp = cdm + csys * diameter * coeffs["cm"] / coeffs["cN"] - assert reconstructed_cp == pytest.approx(aerodynamic_center, abs=1e-3) +def test_length_orientation_independent(calisto_robust, calisto_nose_to_tail): + """The same physical rocket has the same length in either orientation.""" + # Mirror of calisto_robust: nose tip at z=0, then every surface reference + # sits at the same distance from the nose tip as in calisto_robust, so the + # physical rocket (and its length) is identical. The aft-most point is the + # tail base at z = 2.473 + 0.060 = 2.533. + calisto_nose_to_tail.add_nose(length=0.55829, kind="vonKarman", position=0.0) + calisto_nose_to_tail.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=2.473 + ) + calisto_nose_to_tail.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.040, span=0.100, position=2.328 + ) + assert calisto_nose_to_tail.length == pytest.approx(calisto_robust.length, abs=1e-9) -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.""" +def test_length_extends_to_nozzle_past_surfaces(calisto_robust, cesaroni_m1670): + """When the motor nozzle extends aft of the last aerodynamic surface, the + length runs from the nose tip to the nozzle rather than to the surface.""" rocket = calisto_robust - coeffs = rocket.aerodynamic_coefficients(np.radians(5), 0.0, 0.3) - assert set(coeffs) == {"normal_force", "pitch_moment"} + # Move the motor aft so its nozzle (at the motor origin, z = -2.0 in the + # rocket frame) sits past the tail base at z = -1.373. + rocket.add_motor(cesaroni_m1670, position=-2.0) + assert rocket.nozzle_position == pytest.approx(-2.0, abs=1e-9) + assert rocket.length == pytest.approx(1.160 - (-2.0), abs=1e-9) - 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_length_requires_a_surface(calisto): + """A rocket with no aerodynamic surfaces has no defined length.""" + with pytest.raises(ValueError, match="at least one aerodynamic surface"): + _ = calisto.length def test_axisymmetric_rocket_planes_coincide(calisto_robust): @@ -56,39 +79,252 @@ 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 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.""" +def test_add_full_body_aerodynamics(calisto_robust): + """A prebuilt full-body surface is added and contributes to the rocket + aggregate (rocket-as-GenericSurface).""" rocket = calisto_robust - coeffs = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3) - assert set(coeffs) == {"cN", "cY", "cA", "cm", "cn", "cl"} + base_slope = rocket.total_lift_coeff_der.get_value_opt(0.3) + n_before = len(rocket.aerodynamic_surfaces) + + surface = _full_body_surface(rocket, {"cN": lambda a, b, m, re, p, q, r: 2.0 * a}) + returned = rocket.add_full_body_aerodynamics(surface) + + assert returned is surface + assert len(rocket.aerodynamic_surfaces) == n_before + 1 + # A single surface is active during the whole flight by default. + assert surface.active_during == "always" + # The full-body surface exposes the uniform coefficient accessors. + assert surface.cN(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( + 2.0 * np.radians(5) + ) + # Its normal-force slope adds to the rocket aggregate lift-curve slope. + assert rocket.total_lift_coeff_der.get_value_opt(0.3) > base_slope + + +def test_add_full_body_aerodynamics_linear_surface_with_damping(calisto_robust): + """A LinearGenericSurface stability-derivative set (with pitch and roll + damping) is accepted as a full-body model, and its damping derivatives + stay inspectable as named attributes.""" + rocket = calisto_robust + surface = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cN_alpha": 2.0, + "cm_alpha": -1.0, + "cm_q": -50.0, + "cl_p": -5.0, + }, + name="Full body derivatives", + ) + # overwrite clears the built-in drag even though this surface carries none. + with pytest.warns(UserWarning, match="were cleared"): + created = rocket.add_full_body_aerodynamics(surface, overwrite=True) + + assert created is surface + assert len(rocket.aerodynamic_surfaces) == 1 + assert surface.cm_q.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx(-50.0) + # The surface has no drag coefficient, so the rocket now has no drag at all. + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + + +def test_to_surface_round_trips(calisto_robust, cesaroni_m1670): + """``to_surface`` lumps the whole rocket into a power-off/power-on pair of + :class:`LinearGenericSurface` (carrying the pitch/yaw/roll damping and each + phase's drag) that reproduces the rocket's stability when added to a bare + rocket -- the inverse of ``add_full_body_aerodynamics``.""" + surfaces = calisto_robust.to_surface() + assert isinstance(surfaces, list) and len(surfaces) == 2 + power_off, power_on = surfaces + assert all(isinstance(s, LinearGenericSurface) for s in surfaces) + assert power_off.active_during == "power_off" + assert power_on.active_during == "power_on" + # The rate damping is captured as named, inspectable derivatives. + assert power_off.cm_q.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) < 0 # pitch damping + assert power_off.cl_p.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) < 0 # roll damping + # Each surface carries its own phase's drag as the axial coefficient. + assert power_off.cA.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + calisto_robust.power_off_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + assert power_on.cA.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + calisto_robust.power_on_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + + # A bare rocket (same body and motor) carrying only the lumped pair + # reproduces the modeled rocket's static margin and aerodynamic center. The + # surfaces carry the drag, so overwrite clears the bare rocket's curves. + bare = 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", + ) + bare.add_motor(cesaroni_m1670, position=-1.373) + with pytest.warns(UserWarning, match="were cleared"): + bare.add_full_body_aerodynamics(surfaces, overwrite=True) + + assert bare.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert bare.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert bare.static_margin(0) == pytest.approx( + calisto_robust.static_margin(0), rel=1e-3 + ) + for mach in (0.3, 0.8, 1.5): + assert bare.aerodynamic_center.get_value_opt(mach) == pytest.approx( + calisto_robust.aerodynamic_center.get_value_opt(mach), abs=1e-3 + ) + + +def test_to_coefficients_returns_phase_pair(calisto_robust): + """``to_coefficients`` returns a ``power_off``/``power_on`` pair of coefficient + sets, each a dict of Mach curves. The stability derivatives are the same in + both; only the drag ``cA_0`` differs by motor phase.""" + coeffs = calisto_robust.to_coefficients() + assert set(coeffs) == {"power_off", "power_on"} + body_keys = { + "cN_alpha", + "cm_alpha", + "cN_q", + "cm_q", + "cY_beta", + "cn_beta", + "cY_r", + "cn_r", + "cl_p", + "cA_0", + } + assert set(coeffs["power_off"]) == body_keys + assert set(coeffs["power_on"]) == body_keys + assert all(isinstance(c, Function) for c in coeffs["power_off"].values()) - low = rocket.aerodynamic_coefficients_full(np.radians(2), 0.0, 0.3) - assert coeffs["cN"] > low["cN"] > 0 - assert coeffs["cm"] < 0 # restoring pitch moment about the center of dry mass - assert coeffs["cA"] == pytest.approx( - rocket.power_off_drag_by_mach.get_value_opt(0.3) + # Stability derivatives are identical between phases; only drag differs. + assert coeffs["power_on"]["cN_alpha"].get_value_opt(0.3) == pytest.approx( + coeffs["power_off"]["cN_alpha"].get_value_opt(0.3) ) + assert coeffs["power_off"]["cA_0"].get_value_opt(0.3) == pytest.approx( + calisto_robust.power_off_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + assert coeffs["power_on"]["cA_0"].get_value_opt(0.3) == pytest.approx( + calisto_robust.power_on_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + + # It is exactly what ``to_surface`` wraps into surfaces. + power_off, _ = calisto_robust.to_surface() + assert power_off.cN_alpha.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + coeffs["power_off"]["cN_alpha"].get_value_opt(0.3) + ) + + # Wind naming flows through to each phase set. + wind = calisto_robust.to_coefficients(force_convention="wind") + assert "cL_alpha" in wind["power_off"] and "cD_0" in wind["power_off"] + with pytest.raises(ValueError, match="force_convention"): + calisto_robust.to_coefficients(force_convention="bogus") + + +def test_to_surface_force_convention(calisto_robust): + """``force_convention`` selects the coefficient naming (body ``cN``/``cA`` vs + wind ``cL``/``cD``) while yielding an equivalent surface pair.""" + args = (0, 0, 0.3, 0, 0, 0, 0) + drag = calisto_robust.power_off_drag_by_mach.get_value_opt(0.3) + + body_off, _ = calisto_robust.to_surface(force_convention="body") + wind_off, _ = calisto_robust.to_surface(force_convention="wind") + assert body_off.force_convention == "body" + assert wind_off.force_convention == "wind" + # Same underlying model either way (both store body-frame derivatives). + assert wind_off.cN_alpha.get_value_opt(*args) == pytest.approx( + body_off.cN_alpha.get_value_opt(*args) + ) + # The body axial and wind drag both equal the phase's drag. + assert body_off.cA.get_value_opt(*args) == pytest.approx(drag, rel=1e-6) + assert wind_off.cD.get_value_opt(*args) == pytest.approx(drag, rel=1e-6) + with pytest.raises(ValueError, match="force_convention"): + calisto_robust.to_surface(force_convention="bogus") -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).""" + +def test_add_full_body_aerodynamics_power_on_off(calisto_robust): + """A power-on/power-off pair, passed as a list, adds two phase-gated + full-body surfaces.""" rocket = calisto_robust - 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={"cN": lambda a, b, m, re, p, q, r: 2.0 * a} + power_on = _full_body_surface( + rocket, {"cD": 0.3}, name="Full body (power on)", active_during="power_on" + ) + power_off = _full_body_surface( + rocket, {"cD": 0.5}, name="Full body (power off)", active_during="power_off" ) + created = rocket.add_full_body_aerodynamics([power_on, power_off]) - assert len(rocket.aerodynamic_surfaces) == n_before + 1 - # The vehicle surface exposes the uniform coefficient accessors. - assert surface.cN(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( - 2.0 * np.radians(5) + assert len(created) == 2 + assert len(rocket.aerodynamic_surfaces) == n_before + 2 + assert created[0].active_during == "power_on" + assert created[1].active_during == "power_off" + + +def test_add_full_body_aerodynamics_single_phase(calisto_robust): + """A single phase-gated surface (e.g. base drag after burnout) may be added + on its own.""" + rocket = calisto_robust + surface = _full_body_surface(rocket, {"cD": 0.5}, active_during="power_off") + created = rocket.add_full_body_aerodynamics(surface) + assert created is surface + assert surface.active_during == "power_off" + + +def test_add_full_body_aerodynamics_overwrite_replaces_surfaces_and_drag( + calisto_robust, +): + """overwrite=True removes existing surfaces and clears both built-in drag + curves; the supplied surface then provides the only drag.""" + rocket = calisto_robust + assert len(rocket.aerodynamic_surfaces) > 1 + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) > 0 + + surface = _full_body_surface( + rocket, {"cA": 0.4, "cN": lambda a, b, m, re, p, q, r: 2.0 * a} ) - # 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 + with pytest.warns(UserWarning, match="were cleared"): + rocket.add_full_body_aerodynamics(surface, overwrite=True) + + # Only the full-body surface remains. + assert len(rocket.aerodynamic_surfaces) == 1 + assert rocket.aerodynamic_surfaces[0].component is surface + # Both built-in drag curves were cleared (the surface carries the drag now). + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + + +def test_add_full_body_aerodynamics_overwrite_always_clears_drag(calisto_robust): + """overwrite=True clears both built-in drag curves unconditionally, even for + a phase-gated surface that carries no drag of its own.""" + rocket = calisto_robust + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) > 0 + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) > 0 + + power_on = _full_body_surface(rocket, {"cD": 0.3}, active_during="power_on") + power_off = _full_body_surface(rocket, {"cN": 1.0}, active_during="power_off") + with pytest.warns(UserWarning, match="were cleared"): + rocket.add_full_body_aerodynamics([power_on, power_off], overwrite=True) + + # Both curves are cleared regardless of phase or whether a surface carries + # drag; the supplied surfaces are the complete aerodynamics. + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + + +def test_add_full_body_aerodynamics_overwrite_warns_on_later_add(calisto_robust): + """After an overwrite, adding another surface warns that it stacks on the + full-body model.""" + rocket = calisto_robust + surface = _full_body_surface(rocket, {"cD": 0.4}) + with pytest.warns(UserWarning): # the overwrite itself warns about drag + rocket.add_full_body_aerodynamics(surface, overwrite=True) + + later = _full_body_surface(rocket, {"cN": 1.0}) + with pytest.warns(UserWarning, match="stacks on|summed on top"): + rocket.add_full_body_aerodynamics(later) diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py index 01c9e9f51..b74513554 100644 --- a/tests/unit/simulation/test_flight.py +++ b/tests/unit/simulation/test_flight.py @@ -688,6 +688,65 @@ def test_linear_generic_surface_flight_is_stable( assert np.nanmax(np.abs(angle_of_attack)) < 45 +@pytest.mark.parametrize( + "generic_rocket_name, apogee_rel_tol", + [ + ("calisto_linear_generic", 5e-3), + ("calisto_generic", 5e-3), + ("calisto_full_aerodynamics", 1e-2), + ], +) +def test_generic_surface_calisto_flight_matches_barrowman( + request, calisto_robust, example_plain_env, generic_rocket_name, apogee_rel_tol +): + """A Calisto rebuilt from generic surfaces flies the same as the Barrowman + Calisto. + + The reference ``calisto_robust`` models each surface with the classic + Barrowman method. The three generic-surface Calistos carry the same + aerodynamics through different code paths: per-surface + ``LinearGenericSurface`` (coefficient slopes), per-surface + ``GenericSurface`` (total coefficients), and a single full-body + ``LinearGenericSurface`` added with ``add_full_body_aerodynamics`` (the lumped + stability-derivative set, including the rate damping the distributed + surfaces produce through their lever arms). Flown from the same launcher in + still air, each must reach essentially the same apogee and leave the rail at + the same time and speed. Only the ascent is compared (``terminate_on_apogee``); + the descent under identical parachutes adds nothing to the comparison. + + The per-surface models reproduce the Barrowman forces almost exactly; the + lumped full-body model is a point approximation of the distributed + surfaces, so it is held to a slightly looser apogee tolerance. + """ + generic_rocket = request.getfixturevalue(generic_rocket_name) + + launch = dict( + environment=example_plain_env, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=True, + ) + reference_flight = Flight(rocket=calisto_robust, **launch) + generic_flight = Flight(rocket=generic_rocket, **launch) + + # Leaving the rail is driven by thrust and the shared drag curve, so every + # model must agree tightly here. + assert generic_flight.out_of_rail_time == pytest.approx( + reference_flight.out_of_rail_time, rel=1e-3 + ) + assert generic_flight.out_of_rail_velocity == pytest.approx( + reference_flight.out_of_rail_velocity, rel=1e-3 + ) + # Apogee reflects the whole aerodynamic ascent. + assert generic_flight.apogee == pytest.approx( + reference_flight.apogee, rel=apogee_rel_tol + ) + assert generic_flight.apogee_time == pytest.approx( + reference_flight.apogee_time, rel=apogee_rel_tol + ) + + def test_max_acceleration_power_off_time_with_controllers( flight_calisto_air_brakes, ): From c926bdd7d20ebf02572d7564884ca09f3c670589 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 24 Jul 2026 17:34:13 -0300 Subject: [PATCH 10/10] DOC: creat CP rst page and review reference docs --- .pylintrc | 14 + .../stability_dispersion.errors.txt | 0 .../stability_dispersion.inputs.txt | 100 ++ .../stability_dispersion.outputs.txt | 100 ++ docs/reference/classes/Function.rst | 3 + docs/reference/classes/PointMassRocket.rst | 5 + .../ControllableGenericSurface.rst | 5 + .../reference/classes/aero_surfaces/index.rst | 3 +- .../monte_carlo/stochastic_models/index.rst | 1 + .../stochastic_air_brakes.rst | 5 + docs/reference/classes/motors/EmptyMotor.rst | 5 + .../classes/motors/PointMassMotor.rst | 5 + docs/reference/classes/motors/index.rst | 2 + docs/reference/index.rst | 1 + docs/static/rocket/aeroframe.png | Bin 40926 -> 44915 bytes docs/static/rocket/cal-per-length.png | Bin 0 -> 10274 bytes docs/static/rocket/damped-oscillation.png | Bin 0 -> 102752 bytes docs/static/rocket/stable-unstable.png | Bin 0 -> 110823 bytes .../center_of_pressure_and_stability.rst | 405 ------- .../aerodynamics/elliptical_fins.rst | 4 + .../technical/aerodynamics/roll_equations.rst | 4 + docs/technical/index.rst | 1 - .../user/center_of_pressure_and_stability.rst | 1064 +++++++++++++++++ docs/user/first_simulation.rst | 20 +- docs/user/flight.rst | 3 +- docs/user/index.rst | 3 +- docs/user/rocket/generic_surface.rst | 700 ++++++----- docs/user/rocket/rocket.rst | 3 +- 28 files changed, 1760 insertions(+), 696 deletions(-) create mode 100644 data/monte_carlo/stability_dispersion.errors.txt create mode 100644 data/monte_carlo/stability_dispersion.inputs.txt create mode 100644 data/monte_carlo/stability_dispersion.outputs.txt create mode 100644 docs/reference/classes/PointMassRocket.rst create mode 100644 docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst create mode 100644 docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst create mode 100644 docs/reference/classes/motors/EmptyMotor.rst create mode 100644 docs/reference/classes/motors/PointMassMotor.rst create mode 100644 docs/static/rocket/cal-per-length.png create mode 100644 docs/static/rocket/damped-oscillation.png create mode 100644 docs/static/rocket/stable-unstable.png delete mode 100644 docs/technical/aerodynamics/center_of_pressure_and_stability.rst create mode 100644 docs/user/center_of_pressure_and_stability.rst diff --git a/.pylintrc b/.pylintrc index 9e4fd8e89..feb85e897 100644 --- a/.pylintrc +++ b/.pylintrc @@ -233,6 +233,20 @@ good-names=FlightPhases, Re, # Reynolds number cL_alpha, cQ_beta, + cN_alpha, + cY_beta, + cL, + cD, + cQ, + cN, + cY, + cA, + cNf, + cNd, + cYf, + cYd, + cAf, + cAd, # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted diff --git a/data/monte_carlo/stability_dispersion.errors.txt b/data/monte_carlo/stability_dispersion.errors.txt new file mode 100644 index 000000000..e69de29bb diff --git a/data/monte_carlo/stability_dispersion.inputs.txt b/data/monte_carlo/stability_dispersion.inputs.txt new file mode 100644 index 000000000..29f758544 --- /dev/null +++ b/data/monte_carlo/stability_dispersion.inputs.txt @@ -0,0 +1,100 @@ +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.521507153174057, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.822363841581048, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.009075430781169808, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6351.369486474051, "burn_start_time": 0, "burn_out_time": 4.12503921194419, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 1} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5101556242274552, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.013242161800042, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.004636885474406456, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5983.494032079531, "burn_start_time": 0, "burn_out_time": 3.945911628888069, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 2} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.8860073805903463, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.96150876992343, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.015720218955994004, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6162.926243427371, "burn_start_time": 0, "burn_out_time": 3.82953677131187, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 3} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2431228373091128, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.429987350290446, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.023425570557600188, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6240.047946584141, "burn_start_time": 0, "burn_out_time": 3.8479921589454285, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 4} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8926107317108068, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.909315780860616, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.03313420098540572, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5898.780443757008, "burn_start_time": 0, "burn_out_time": 3.5504898781516396, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 5} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.254135896199279, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.73459606153309, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0465851431774179, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5859.83559479326, "burn_start_time": 0, "burn_out_time": 4.071184289884895, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 6} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.46171044281572615, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.97626609539867, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0036886485744965693, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5902.798595639467, "burn_start_time": 0, "burn_out_time": 3.7758921947544137, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 7} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.55414505653343, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.993967505335391, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.015453909077576865, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5871.598134248473, "burn_start_time": 0, "burn_out_time": 3.9627164054022304, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 8} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.7088223255245034, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.648851358355651, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.03796465369699778, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5865.428226638956, "burn_start_time": 0, "burn_out_time": 3.858357488909442, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 9} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1025774678540878, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.207537580418032, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.026502636458545295, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6080.884653022599, "burn_start_time": 0, "burn_out_time": 3.9166936295605166, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 10} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0607750200071973, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.867283874921611, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.014070821764981069, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5913.870418610246, "burn_start_time": 0, "burn_out_time": 3.795666925127526, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 11} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9941527130896458, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.812450062594092, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.037195032384504306, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6029.23942711598, "burn_start_time": 0, "burn_out_time": 4.042021691795649, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 12} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.46697295282380413, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.549898786101538, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04142063457938412, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6072.075417218228, "burn_start_time": 0, "burn_out_time": 3.7317099503587103, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 13} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1040404742345593, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.393044684018475, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.016789306703718795, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6055.03282805908, "burn_start_time": 0, "burn_out_time": 3.93846869900211, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 14} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5874177070288262, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.648465635332474, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.06073837606411138, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6094.522067960042, "burn_start_time": 0, "burn_out_time": 3.8996269564143557, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 15} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5378823500699033, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.800129553482858, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.017046253278285635, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5757.311036017226, "burn_start_time": 0, "burn_out_time": 3.8178832949370203, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 16} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7284185586893085, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.726131791473186, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.016883248625913692, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5942.124888379175, "burn_start_time": 0, "burn_out_time": 3.996403159641596, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 17} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8175942187227592, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.20152500283439, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.027534072203136414, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6259.565732212167, "burn_start_time": 0, "burn_out_time": 3.7138081473887206, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 18} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.182223283464123, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.16214884924183, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.06702705968258874, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5866.122801560027, "burn_start_time": 0, "burn_out_time": 3.9095582858310367, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 19} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0576006868772183, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.908316473087542, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0020487835079548013, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5715.863945166125, "burn_start_time": 0, "burn_out_time": 3.8879546467766133, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 20} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1443098910854685, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.426686716582134, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.03076383150584279, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6132.172664640067, "burn_start_time": 0, "burn_out_time": 4.1610413858966755, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 21} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 2.39999788029899, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.369548756752208, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.009270854202445727, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6238.992928485764, "burn_start_time": 0, "burn_out_time": 3.90111241815629, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 22} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.293822694225872, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.019396008421712, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02570113710478727, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5784.398808839861, "burn_start_time": 0, "burn_out_time": 3.8158631584599236, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 23} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.3696123040255518, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.319897184503134, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0067417896740935, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6025.818253677406, "burn_start_time": 0, "burn_out_time": 3.9664257312961833, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 24} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2795214625994893, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.640737192956678, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02424482969599957, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5584.243936627794, "burn_start_time": 0, "burn_out_time": 3.8694447429507153, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 25} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2054186202923738, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.316554346919487, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02423275857658752, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5844.0664752939965, "burn_start_time": 0, "burn_out_time": 3.823183841031567, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 26} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.936358889318001, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.09734876707819, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02175727081984967, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6160.027005816634, "burn_start_time": 0, "burn_out_time": 3.670503736564817, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 27} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8914548477758035, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.86849579830338, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.031989757028450055, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5835.549869239558, "burn_start_time": 0, "burn_out_time": 3.9543773917109073, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 28} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0553928717948868, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.896803649679448, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.03566275146481105, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5882.704323776219, "burn_start_time": 0, "burn_out_time": 3.569840304601262, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 29} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.207516947004454, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.351766404064886, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.025392739753787856, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5870.51239692753, "burn_start_time": 0, "burn_out_time": 3.798500859164062, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 30} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5682653051852451, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.01597603827496, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02042918984075148, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6045.494568639416, "burn_start_time": 0, "burn_out_time": 3.817798778488695, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 31} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9175796846096277, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.691140822923222, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.006394804012495172, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5985.006088195862, "burn_start_time": 0, "burn_out_time": 4.078608312248814, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 32} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6123885409030086, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.462521029606126, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04072157962131368, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5891.4504055681955, "burn_start_time": 0, "burn_out_time": 4.06406339414674, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 33} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3275365266684764, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.102685448648783, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.026436721281435635, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5873.239513595609, "burn_start_time": 0, "burn_out_time": 3.9167907338105947, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 34} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5094400871361682, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.456487188673387, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.019746563706290392, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6249.26984689557, "burn_start_time": 0, "burn_out_time": 3.9106300099876163, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 35} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5576953122535993, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.262613068044427, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.09904871755174288, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5523.178363080369, "burn_start_time": 0, "burn_out_time": 3.785049044431886, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 36} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5951710875788105, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.307258749830067, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02712461956013373, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5859.122857763266, "burn_start_time": 0, "burn_out_time": 3.997255266703495, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 37} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6042359768386178, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.396046500655638, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.012537707606567899, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6182.455189093834, "burn_start_time": 0, "burn_out_time": 3.8092829493009672, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 38} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.537286587820295, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.65609753034603, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.009368037234779858, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5974.225982881899, "burn_start_time": 0, "burn_out_time": 4.042183750887245, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 39} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8991522374654737, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.0246729579954, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.019635003344855777, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6116.434480715165, "burn_start_time": 0, "burn_out_time": 4.039795635951713, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 40} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6262644799866924, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.740086052609113, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.059654899753242896, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5875.4025378143315, "burn_start_time": 0, "burn_out_time": 3.803868195081801, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 41} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3002166986540749, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.326751348183375, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.013501633642283342, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5705.098185346435, "burn_start_time": 0, "burn_out_time": 3.995731621890093, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 42} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1756277800770079, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.368392660064638, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0017475555005954622, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6189.217860975409, "burn_start_time": 0, "burn_out_time": 3.902670054509228, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 43} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2264357481438815, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.507963833339327, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.024072971929202085, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5566.4401831124715, "burn_start_time": 0, "burn_out_time": 3.8104288499991212, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 44} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7386724525231617, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.870729806595604, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.006292712294764745, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6264.747749775479, "burn_start_time": 0, "burn_out_time": 3.7704312887018943, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 45} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7724596974683736, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.999672753172847, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.004663092153944251, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5972.978051890591, "burn_start_time": 0, "burn_out_time": 4.092376386260005, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 46} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9203388845149081, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.086227593906028, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0020316024888011033, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6122.174947456201, "burn_start_time": 0, "burn_out_time": 3.639430487024025, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 47} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9057076484860059, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.195050595784386, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.010979306129075314, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6183.99044808285, "burn_start_time": 0, "burn_out_time": 3.7869769431052394, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 48} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.944015615328221, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.667504678312683, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.016707499844365828, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5716.794045225628, "burn_start_time": 0, "burn_out_time": 3.7606244323855726, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 49} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2615864841275002, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.035418400789801, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.009812389838004726, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6133.307007528654, "burn_start_time": 0, "burn_out_time": 4.087879106827424, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 50} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.6463695176146074, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.542959130559593, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.010273686526313015, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6015.209766382824, "burn_start_time": 0, "burn_out_time": 3.8703268054917888, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 51} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5135563974711828, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.303402199529199, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.013530486519409041, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6115.245210948932, "burn_start_time": 0, "burn_out_time": 4.132944036078017, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 52} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9950124966474333, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.973205637367604, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02118627177585532, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6439.623772817847, "burn_start_time": 0, "burn_out_time": 3.790244011471183, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 53} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8952534389366245, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.20136908420973, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.028202427971494873, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6357.586210966299, "burn_start_time": 0, "burn_out_time": 3.9098894495830683, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 54} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.8119021747133472, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.401883634429852, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.002746598433826927, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6121.916776050539, "burn_start_time": 0, "burn_out_time": 3.94827370999028, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 55} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3565978706444604, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.446310311738237, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.025894748215433683, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6310.233721346618, "burn_start_time": 0, "burn_out_time": 3.6951702164329947, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 56} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.687232953077063, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.532365798315034, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.05428992670716373, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6174.638985492842, "burn_start_time": 0, "burn_out_time": 3.6145371851491293, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 57} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0425250781016568, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.354923966362001, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.006373972867909677, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5920.667688712692, "burn_start_time": 0, "burn_out_time": 3.9242006196990986, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 58} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.719249443748984, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.729912766150282, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.05161241337215586, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6064.957536270543, "burn_start_time": 0, "burn_out_time": 3.8506612045014617, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 59} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3615077513889289, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.908469054240625, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.08184890736311479, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5987.77381956212, "burn_start_time": 0, "burn_out_time": 3.9174830063606754, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 60} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7296967439091322, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.907292761046216, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.007481289303339345, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6157.929433080301, "burn_start_time": 0, "burn_out_time": 4.1990572604783845, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 61} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8170566698395048, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.498580985730609, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.012294525829345546, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6022.11800221605, "burn_start_time": 0, "burn_out_time": 4.0125637353043855, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 62} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9125305207120032, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.521548781832555, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.045109402273743916, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6243.94197224416, "burn_start_time": 0, "burn_out_time": 3.946254684544609, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 63} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2589251167875195, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.27557318272878, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04159207767593187, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6202.391712399047, "burn_start_time": 0, "burn_out_time": 3.8286369437045558, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 64} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4558048826919037, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.8688875499481, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.003760506582064991, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6288.907582638557, "burn_start_time": 0, "burn_out_time": 3.7717572955847314, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 65} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5342419823403632, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.432782489983293, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.01110808191311571, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5798.13280897443, "burn_start_time": 0, "burn_out_time": 3.833237972920794, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 66} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9282502375421586, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.841841386049571, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.013028115070091575, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5807.51370398012, "burn_start_time": 0, "burn_out_time": 4.054554444037106, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 67} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1746583599849303, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.230468992233321, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.01957833897886917, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5904.789675660768, "burn_start_time": 0, "burn_out_time": 3.7668353891076993, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 68} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0566482097887433, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.34698723599429, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.024339364380413283, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5918.695527443453, "burn_start_time": 0, "burn_out_time": 3.909454972167619, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 69} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9160490054703169, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.697383279594456, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0035034024542409882, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6176.448020136504, "burn_start_time": 0, "burn_out_time": 3.9357019038950476, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 70} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4728794970707417, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.400658743131533, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.011498337870130942, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5940.5398503802435, "burn_start_time": 0, "burn_out_time": 3.7743514260251443, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 71} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9551785926079897, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.673284533471286, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.002265113898276123, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6136.547455572391, "burn_start_time": 0, "burn_out_time": 4.019126904768468, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 72} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.6747479175273858, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.338383644151882, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.050748580125890695, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5918.984184895319, "burn_start_time": 0, "burn_out_time": 3.8432044351883303, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 73} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.20930783895083, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.025666731308315, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.033771052729587685, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5870.783539016162, "burn_start_time": 0, "burn_out_time": 3.9296585219950386, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 74} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5127824544803135, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.321336172817102, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.002123495677305162, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6141.030408134489, "burn_start_time": 0, "burn_out_time": 3.865639146123638, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 75} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9707156297310905, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.9814366636166, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04296958102248286, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5843.492024855827, "burn_start_time": 0, "burn_out_time": 3.6696276440798714, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 76} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8805447482432478, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.301052853428859, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.013392687542252075, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6213.415672590566, "burn_start_time": 0, "burn_out_time": 3.9801881318186956, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 77} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4437365459566989, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.085246711792577, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.020114198713927584, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5897.848664284219, "burn_start_time": 0, "burn_out_time": 3.8668279463451256, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 78} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1255112592418468, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.717762886257601, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.004694373695493757, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6038.768048346616, "burn_start_time": 0, "burn_out_time": 4.059209455837512, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 79} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6584161847708666, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.146583403511169, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.03658997791311692, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5922.217491962746, "burn_start_time": 0, "burn_out_time": 3.889764929702553, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 80} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.493056798892862, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.211092341519155, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.07472732670491032, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5642.947335059451, "burn_start_time": 0, "burn_out_time": 3.810941867812397, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 81} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9466845221896786, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.83649395356574, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.045962645546592774, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6089.837469678587, "burn_start_time": 0, "burn_out_time": 4.108948943442876, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 82} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5873184088958892, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.45296065327586, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.039833682969637185, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5870.432062012816, "burn_start_time": 0, "burn_out_time": 4.078352974580726, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 83} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.504207431858531, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.49806030718616, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.022580806254653748, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6146.017070588164, "burn_start_time": 0, "burn_out_time": 3.9402205721711714, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 84} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0255030912059533, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.00897763805044, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.019836567711375014, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5813.458476313082, "burn_start_time": 0, "burn_out_time": 3.88867826491503, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 85} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.6390632196895947, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.594597985644617, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02260159354554305, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6081.372272714397, "burn_start_time": 0, "burn_out_time": 3.96510856973297, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 86} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5113588097444572, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.624391446002114, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.034001386215105234, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6139.575197727543, "burn_start_time": 0, "burn_out_time": 3.991457881237835, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 87} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.243510943209721, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.976075370949667, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.004847739923519672, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6171.794407727714, "burn_start_time": 0, "burn_out_time": 3.9198447264490057, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 88} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5055627982087956, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.947326187342245, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.05175050585857492, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5599.951690575628, "burn_start_time": 0, "burn_out_time": 3.919343321576347, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 89} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3406846359067714, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.77097079101815, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.009379489909334264, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6008.39094811574, "burn_start_time": 0, "burn_out_time": 3.892738655532904, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 90} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7690176739612495, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.437087496321743, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0022677989336368077, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5753.755185770554, "burn_start_time": 0, "burn_out_time": 3.543805339463317, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 91} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.7234523543403084, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.668031297859383, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.01267243863095468, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5907.183481136588, "burn_start_time": 0, "burn_out_time": 3.99830081031704, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 92} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8979368903909795, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.145400151980878, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0028027144959191248, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5605.18983942218, "burn_start_time": 0, "burn_out_time": 3.851160445753806, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 93} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5644393984521076, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.368414654819071, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.012667904026666075, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5953.662553791602, "burn_start_time": 0, "burn_out_time": 3.930065229489668, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 94} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3960659404942042, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.128520301417089, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.04009907540013144, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6129.468922973201, "burn_start_time": 0, "burn_out_time": 3.980673380053079, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 95} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4368577379881373, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.4490630583561, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.012006442834018378, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6227.537736602414, "burn_start_time": 0, "burn_out_time": 4.182803626540288, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 96} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6186593834110099, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.92993878871148, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04550398951057217, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5744.841798853582, "burn_start_time": 0, "burn_out_time": 4.115253821341056, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 97} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9374292052792661, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.177883544279485, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.005518442012212102, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6038.054475762916, "burn_start_time": 0, "burn_out_time": 4.0014644012448795, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 98} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2490520218025916, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.965331899250637, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.04594684816415277, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6176.670635311683, "burn_start_time": 0, "burn_out_time": 3.851633650387373, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 99} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3143667461743567, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.153955882317693, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.01017422126766747, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6056.5531267118, "burn_start_time": 0, "burn_out_time": 3.9547655913184028, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 100} diff --git a/data/monte_carlo/stability_dispersion.outputs.txt b/data/monte_carlo/stability_dispersion.outputs.txt new file mode 100644 index 000000000..5d5330263 --- /dev/null +++ b/data/monte_carlo/stability_dispersion.outputs.txt @@ -0,0 +1,100 @@ +{"apogee": 4861.059128669208, "y_impact": 0, "out_of_rail_time": 0.423769074499993, "apogee_x": -146.93079438169238, "apogee_time": 26.51594339163099, "frontal_surface_wind": 0.0, "apogee_y": 616.1470208873146, "out_of_rail_stability_margin": 2.265281592202937, "t_final": 26.51594339163099, "x_impact": 0, "initial_stability_margin": 2.1798501057629904, "out_of_rail_velocity": 30.0308932593615, "lateral_surface_wind": -1.564521459522171, "max_mach_number": 0.881174939443053, "impact_velocity": 0, "index": 1, "rail_exit_aoa": 2.9822459742928653} +{"apogee": 4745.968264912956, "y_impact": 0, "out_of_rail_time": 0.41529467918564494, "apogee_x": -134.73394030108742, "apogee_time": 25.985672171421474, "frontal_surface_wind": 0.0, "apogee_y": 589.5509953139139, "out_of_rail_stability_margin": 2.2795408162857553, "t_final": 25.985672171421474, "x_impact": 0, "initial_stability_margin": 2.18777343525097, "out_of_rail_velocity": 30.518799341367785, "lateral_surface_wind": -1.5304668726823656, "max_mach_number": 0.8722481605013893, "impact_velocity": 0, "index": 2, "rail_exit_aoa": 2.8708827501670675} +{"apogee": 4675.6302173772065, "y_impact": 0, "out_of_rail_time": 0.41184706108715086, "apogee_x": -473.2569592832319, "apogee_time": 25.77461921098828, "frontal_surface_wind": 0.0, "apogee_y": 579.840763939317, "out_of_rail_stability_margin": 2.4323707012514926, "t_final": 25.77461921098828, "x_impact": 0, "initial_stability_margin": 2.3399806052007586, "out_of_rail_velocity": 30.690244962642193, "lateral_surface_wind": -5.658022141771039, "max_mach_number": 0.8578729590647751, "impact_velocity": 0, "index": 3, "rail_exit_aoa": 10.445701536637845} +{"apogee": 4844.720752041134, "y_impact": 0, "out_of_rail_time": 0.40498203713935343, "apogee_x": -321.57511052657213, "apogee_time": 26.277584110699063, "frontal_surface_wind": 0.0, "apogee_y": 602.9921974620992, "out_of_rail_stability_margin": 2.43051917307307, "t_final": 26.277584110699063, "x_impact": 0, "initial_stability_margin": 2.3378016945566022, "out_of_rail_velocity": 31.287670930209153, "lateral_surface_wind": -3.7293685119273383, "max_mach_number": 0.8932516873842207, "impact_velocity": 0, "index": 4, "rail_exit_aoa": 6.797363163210099} +{"apogee": 4705.623905928367, "y_impact": 0, "out_of_rail_time": 0.3899231535612529, "apogee_x": -204.96055806873392, "apogee_time": 25.6632827362864, "frontal_surface_wind": 0.0, "apogee_y": 570.458338750699, "out_of_rail_stability_margin": 2.049885389822403, "t_final": 25.6632827362864, "x_impact": 0, "initial_stability_margin": 1.956418604948673, "out_of_rail_velocity": 32.234321027351704, "lateral_surface_wind": -2.6778321951324204, "max_mach_number": 0.8777734145497106, "impact_velocity": 0, "index": 5, "rail_exit_aoa": 4.748881983000292} +{"apogee": 4505.63794973302, "y_impact": 0, "out_of_rail_time": 0.4354086022554508, "apogee_x": -315.9021992486424, "apogee_time": 25.33527859806564, "frontal_surface_wind": 0.0, "apogee_y": 560.5182031936844, "out_of_rail_stability_margin": 2.03484745543855, "t_final": 25.33527859806564, "x_impact": 0, "initial_stability_margin": 1.9492341899016072, "out_of_rail_velocity": 29.036946263016052, "lateral_surface_wind": -3.762407688597837, "max_mach_number": 0.816386227218562, "impact_velocity": 0, "index": 6, "rail_exit_aoa": 7.382858806844491} +{"apogee": 4514.684938293628, "y_impact": 0, "out_of_rail_time": 0.415858181860322, "apogee_x": -115.25999912212619, "apogee_time": 25.262931916005, "frontal_surface_wind": 0.0, "apogee_y": 555.0276869750553, "out_of_rail_stability_margin": 2.362712584704993, "t_final": 25.262931916005, "x_impact": 0, "initial_stability_margin": 2.2692091584391627, "out_of_rail_velocity": 30.215458624713378, "lateral_surface_wind": -1.3851313284471785, "max_mach_number": 0.8206718067451596, "impact_velocity": 0, "index": 7, "rail_exit_aoa": 2.624704715290225} +{"apogee": 4461.511715946739, "y_impact": 0, "out_of_rail_time": 0.4301351127938697, "apogee_x": -384.8153238877181, "apogee_time": 25.15007667756139, "frontal_surface_wind": 0.0, "apogee_y": 552.0633447681942, "out_of_rail_stability_margin": 2.2460940294107123, "t_final": 25.15007667756139, "x_impact": 0, "initial_stability_margin": 2.1565216396910722, "out_of_rail_velocity": 29.299220993866616, "lateral_surface_wind": -4.66243516960029, "max_mach_number": 0.8100562060710047, "impact_velocity": 0, "index": 8, "rail_exit_aoa": 9.041762861336235} +{"apogee": 4521.329605693292, "y_impact": 0, "out_of_rail_time": 0.41960262862325637, "apogee_x": -409.73060076401305, "apogee_time": 25.267384943094115, "frontal_surface_wind": 0.0, "apogee_y": 555.7828525911232, "out_of_rail_stability_margin": 2.082160667638206, "t_final": 25.267384943094115, "x_impact": 0, "initial_stability_margin": 1.9933619027006095, "out_of_rail_velocity": 30.02148157022414, "lateral_surface_wind": -5.126466976573511, "max_mach_number": 0.8281321990113074, "impact_velocity": 0, "index": 9, "rail_exit_aoa": 9.69035943448747} +{"apogee": 4772.744314445873, "y_impact": 0, "out_of_rail_time": 0.4122332901026502, "apogee_x": -286.85147207454594, "apogee_time": 26.06538048760921, "frontal_surface_wind": 0.0, "apogee_y": 593.7241019671629, "out_of_rail_stability_margin": 2.4284681091114444, "t_final": 26.06538048760921, "x_impact": 0, "initial_stability_margin": 2.335008088501943, "out_of_rail_velocity": 30.742413840466778, "lateral_surface_wind": -3.3077324035622633, "max_mach_number": 0.8784261910919092, "impact_velocity": 0, "index": 10, "rail_exit_aoa": 6.141118598409435} +{"apogee": 4534.280778616781, "y_impact": 0, "out_of_rail_time": 0.41584552879937836, "apogee_x": -260.43098622442227, "apogee_time": 25.31717701287329, "frontal_surface_wind": 0.0, "apogee_y": 558.1170224814891, "out_of_rail_stability_margin": 2.416062309674137, "t_final": 25.31717701287329, "x_impact": 0, "initial_stability_margin": 2.321737157905933, "out_of_rail_velocity": 30.250540044982795, "lateral_surface_wind": -3.1823250600215918, "max_mach_number": 0.8268248081273232, "impact_velocity": 0, "index": 11, "rail_exit_aoa": 6.005367427267921} +{"apogee": 4806.16029727606, "y_impact": 0, "out_of_rail_time": 0.41813169403487865, "apogee_x": -267.07214906306194, "apogee_time": 26.18124821703252, "frontal_surface_wind": 0.0, "apogee_y": 600.4931103282491, "out_of_rail_stability_margin": 2.452801116100952, "t_final": 26.18124821703252, "x_impact": 0, "initial_stability_margin": 2.3590680519526543, "out_of_rail_velocity": 30.40956172265108, "lateral_surface_wind": -2.9824581392689375, "max_mach_number": 0.8860281878611809, "impact_velocity": 0, "index": 12, "rail_exit_aoa": 5.601445444617184} +{"apogee": 4717.960348204425, "y_impact": 0, "out_of_rail_time": 0.40328160824349857, "apogee_x": -120.2480903453828, "apogee_time": 25.85710853308616, "frontal_surface_wind": 0.0, "apogee_y": 582.2411469138832, "out_of_rail_stability_margin": 2.552498553363377, "t_final": 25.85710853308616, "x_impact": 0, "initial_stability_margin": 2.4554958036662318, "out_of_rail_velocity": 31.257346444584027, "lateral_surface_wind": -1.4009188584714125, "max_mach_number": 0.8665078059126148, "impact_velocity": 0, "index": 13, "rail_exit_aoa": 2.5662145728716816} +{"apogee": 4720.60965406142, "y_impact": 0, "out_of_rail_time": 0.4163237446868821, "apogee_x": -282.3060533858383, "apogee_time": 25.93646857427243, "frontal_surface_wind": 0.0, "apogee_y": 587.1203531711153, "out_of_rail_stability_margin": 2.1854355532060943, "t_final": 25.93646857427243, "x_impact": 0, "initial_stability_margin": 2.0965209560065703, "out_of_rail_velocity": 30.412346045302314, "lateral_surface_wind": -3.3121214227036777, "max_mach_number": 0.8650015455286694, "impact_velocity": 0, "index": 14, "rail_exit_aoa": 6.215423114329094} +{"apogee": 4690.372378630645, "y_impact": 0, "out_of_rail_time": 0.4149160638767922, "apogee_x": -411.9646326773905, "apogee_time": 25.828842814319742, "frontal_surface_wind": 0.0, "apogee_y": 583.6316955677842, "out_of_rail_stability_margin": 2.676120911911297, "t_final": 25.828842814319742, "x_impact": 0, "initial_stability_margin": 2.5795551586065315, "out_of_rail_velocity": 30.45847269774805, "lateral_surface_wind": -4.762253121086479, "max_mach_number": 0.8602583432598059, "impact_velocity": 0, "index": 15, "rail_exit_aoa": 8.886382309997451} +{"apogee": 4435.543472272095, "y_impact": 0, "out_of_rail_time": 0.4218049372404265, "apogee_x": -133.16166566528594, "apogee_time": 24.999123854743758, "frontal_surface_wind": 0.0, "apogee_y": 543.9001318951339, "out_of_rail_stability_margin": 2.4290455683959316, "t_final": 24.999123854743758, "x_impact": 0, "initial_stability_margin": 2.3334782467534, "out_of_rail_velocity": 29.76069187636971, "lateral_surface_wind": -1.6136470502097098, "max_mach_number": 0.8059685810484515, "impact_velocity": 0, "index": 16, "rail_exit_aoa": 3.103581153319485} +{"apogee": 4580.983920037511, "y_impact": 0, "out_of_rail_time": 0.42737224293462184, "apogee_x": -187.77260599162355, "apogee_time": 25.557122627129065, "frontal_surface_wind": 0.0, "apogee_y": 570.1945043985857, "out_of_rail_stability_margin": 2.2138894542875613, "t_final": 25.557122627129065, "x_impact": 0, "initial_stability_margin": 2.1251096377952323, "out_of_rail_velocity": 29.57466127317569, "lateral_surface_wind": -2.1852556760679254, "max_mach_number": 0.8308676925273584, "impact_velocity": 0, "index": 17, "rail_exit_aoa": 4.225874626409653} +{"apogee": 4908.954583565018, "y_impact": 0, "out_of_rail_time": 0.39322693163194655, "apogee_x": -203.43530922032105, "apogee_time": 26.39765923785646, "frontal_surface_wind": 0.0, "apogee_y": 606.0631718651757, "out_of_rail_stability_margin": 2.105244675806875, "t_final": 26.39765923785646, "x_impact": 0, "initial_stability_margin": 2.015908978426879, "out_of_rail_velocity": 32.17646928545603, "lateral_surface_wind": -2.4527826561682775, "max_mach_number": 0.9115885873107927, "impact_velocity": 0, "index": 18, "rail_exit_aoa": 4.359174077854781} +{"apogee": 4623.763416388843, "y_impact": 0, "out_of_rail_time": 0.4181429613480069, "apogee_x": -286.81972736127693, "apogee_time": 25.593630056893282, "frontal_surface_wind": 0.0, "apogee_y": 569.8656994173409, "out_of_rail_stability_margin": 1.8654396405111937, "t_final": 25.593630056893282, "x_impact": 0, "initial_stability_margin": 1.779801479263598, "out_of_rail_velocity": 30.216226714958534, "lateral_surface_wind": -3.5466698503923695, "max_mach_number": 0.8489731284834018, "impact_velocity": 0, "index": 19, "rail_exit_aoa": 6.694536568715267} +{"apogee": 4559.72313483547, "y_impact": 0, "out_of_rail_time": 0.4197371455646918, "apogee_x": -260.8213497777298, "apogee_time": 25.347276386566193, "frontal_surface_wind": 0.0, "apogee_y": 559.9242574702647, "out_of_rail_stability_margin": 2.257016257736069, "t_final": 25.347276386566193, "x_impact": 0, "initial_stability_margin": 2.1626207823210555, "out_of_rail_velocity": 30.103065712790247, "lateral_surface_wind": -3.172802060631655, "max_mach_number": 0.8389665706124879, "impact_velocity": 0, "index": 20, "rail_exit_aoa": 6.016645448819411} +{"apogee": 4763.604600054928, "y_impact": 0, "out_of_rail_time": 0.42906626832874717, "apogee_x": -315.7210268624283, "apogee_time": 26.171689584114688, "frontal_surface_wind": 0.0, "apogee_y": 601.4546940348298, "out_of_rail_stability_margin": 2.4714847285355415, "t_final": 26.171689584114688, "x_impact": 0, "initial_stability_margin": 2.3809021220880098, "out_of_rail_velocity": 29.654821926881137, "lateral_surface_wind": -3.432929673256406, "max_mach_number": 0.8675469410697194, "impact_velocity": 0, "index": 21, "rail_exit_aoa": 6.603335912410592} +{"apogee": 4814.344778240577, "y_impact": 0, "out_of_rail_time": 0.4079855886238799, "apogee_x": -613.7051036149487, "apogee_time": 26.15281282379752, "frontal_surface_wind": 0.0, "apogee_y": 598.3296060405976, "out_of_rail_stability_margin": 2.3395167578893528, "t_final": 26.15281282379752, "x_impact": 0, "initial_stability_margin": 2.248758754715839, "out_of_rail_velocity": 31.095775615493523, "lateral_surface_wind": -7.19999364089697, "max_mach_number": 0.8952663645356832, "impact_velocity": 0, "index": 22, "rail_exit_aoa": 13.03667460546709} +{"apogee": 4402.26325146706, "y_impact": 0, "out_of_rail_time": 0.4235899094528802, "apogee_x": -305.77277878980004, "apogee_time": 24.89434324683989, "frontal_surface_wind": 0.0, "apogee_y": 538.7682594418363, "out_of_rail_stability_margin": 2.188325393472822, "t_final": 24.89434324683989, "x_impact": 0, "initial_stability_margin": 2.097371751661811, "out_of_rail_velocity": 29.661724458528745, "lateral_surface_wind": -3.881468082677616, "max_mach_number": 0.8004083556666952, "impact_velocity": 0, "index": 23, "rail_exit_aoa": 7.455238478582467} +{"apogee": 4722.023710925485, "y_impact": 0, "out_of_rail_time": 0.4183920385908822, "apogee_x": -100.0037420622863, "apogee_time": 25.955904587395935, "frontal_surface_wind": 0.0, "apogee_y": 588.4233113760358, "out_of_rail_stability_margin": 2.320090763102421, "t_final": 25.955904587395935, "x_impact": 0, "initial_stability_margin": 2.22888694952396, "out_of_rail_velocity": 30.274374603777034, "lateral_surface_wind": -1.1088369120766552, "max_mach_number": 0.8630101752151198, "impact_velocity": 0, "index": 24, "rail_exit_aoa": 2.0975921065325234} +{"apogee": 4316.204283039229, "y_impact": 0, "out_of_rail_time": 0.4307595751953714, "apogee_x": -306.2403239867814, "apogee_time": 24.58557269432186, "frontal_surface_wind": 0.0, "apogee_y": 526.564683638577, "out_of_rail_stability_margin": 2.4594700406977354, "t_final": 24.58557269432186, "x_impact": 0, "initial_stability_margin": 2.3620419244868227, "out_of_rail_velocity": 29.18734562854441, "lateral_surface_wind": -3.838564387798468, "max_mach_number": 0.7858294261787138, "impact_velocity": 0, "index": 25, "rail_exit_aoa": 7.492237963702455} +{"apogee": 4580.421775577209, "y_impact": 0, "out_of_rail_time": 0.41452788908703814, "apogee_x": -291.6034901465998, "apogee_time": 25.42603518144242, "frontal_surface_wind": 0.0, "apogee_y": 562.5044590752298, "out_of_rail_stability_margin": 2.136741369325951, "t_final": 25.42603518144242, "x_impact": 0, "initial_stability_margin": 2.0457281334462416, "out_of_rail_velocity": 30.40391182341143, "lateral_surface_wind": -3.6162558608771214, "max_mach_number": 0.841173554128255, "impact_velocity": 0, "index": 26, "rail_exit_aoa": 6.782921656055069} +{"apogee": 5009.929189116323, "y_impact": 0, "out_of_rail_time": 0.382551293938355, "apogee_x": -226.6763234833904, "apogee_time": 26.51906703408914, "frontal_surface_wind": 0.0, "apogee_y": 611.6404487702985, "out_of_rail_stability_margin": 2.0359327287972446, "t_final": 26.51906703408914, "x_impact": 0, "initial_stability_margin": 1.9439258635295085, "out_of_rail_velocity": 33.16807109100089, "lateral_surface_wind": -2.8090766679540033, "max_mach_number": 0.9540120327620064, "impact_velocity": 0, "index": 27, "rail_exit_aoa": 4.840952911426418} +{"apogee": 4473.174697512416, "y_impact": 0, "out_of_rail_time": 0.4295268081476819, "apogee_x": -221.0967314085801, "apogee_time": 25.192909588840973, "frontal_surface_wind": 0.0, "apogee_y": 553.0840187822552, "out_of_rail_stability_margin": 2.135617818120419, "t_final": 25.192909588840973, "x_impact": 0, "initial_stability_margin": 2.047245890651631, "out_of_rail_velocity": 29.341399159410663, "lateral_surface_wind": -2.6743645433274104, "max_mach_number": 0.810226085483311, "impact_velocity": 0, "index": 28, "rail_exit_aoa": 5.207917153162725} +{"apogee": 4513.400587084696, "y_impact": 0, "out_of_rail_time": 0.40119146931290356, "apogee_x": -238.96706447262517, "apogee_time": 25.151820119787804, "frontal_surface_wind": 0.0, "apogee_y": 547.5979726767164, "out_of_rail_stability_margin": 2.1199326600159676, "t_final": 25.151820119787804, "x_impact": 0, "initial_stability_margin": 2.0277181282296333, "out_of_rail_velocity": 31.201704719621958, "lateral_surface_wind": -3.1661786153846605, "max_mach_number": 0.8281155376802564, "impact_velocity": 0, "index": 29, "rail_exit_aoa": 5.794229268903359} +{"apogee": 4593.730036373843, "y_impact": 0, "out_of_rail_time": 0.41220339242964765, "apogee_x": -297.59356861774745, "apogee_time": 25.457527846077085, "frontal_surface_wind": 0.0, "apogee_y": 564.7085995136297, "out_of_rail_stability_margin": 2.4382884629087833, "t_final": 25.457527846077085, "x_impact": 0, "initial_stability_margin": 2.3421236989073533, "out_of_rail_velocity": 30.548953646337875, "lateral_surface_wind": -3.622550841013362, "max_mach_number": 0.8442808621017847, "impact_velocity": 0, "index": 30, "rail_exit_aoa": 6.762658357211761} +{"apogee": 4610.972500697442, "y_impact": 0, "out_of_rail_time": 0.41457464090964125, "apogee_x": -145.46287630480748, "apogee_time": 25.600868682592193, "frontal_surface_wind": 0.0, "apogee_y": 571.0929093436595, "out_of_rail_stability_margin": 2.4662707994282553, "t_final": 25.600868682592193, "x_impact": 0, "initial_stability_margin": 2.372654654829949, "out_of_rail_velocity": 30.385901195466825, "lateral_surface_wind": -1.7047959155557353, "max_mach_number": 0.8382622597182194, "impact_velocity": 0, "index": 31, "rail_exit_aoa": 3.2112036133885673} +{"apogee": 4614.00127765572, "y_impact": 0, "out_of_rail_time": 0.43109722574198783, "apogee_x": -241.74457954699653, "apogee_time": 25.694662462849475, "frontal_surface_wind": 0.0, "apogee_y": 577.6693917895657, "out_of_rail_stability_margin": 2.2731060581830125, "t_final": 25.694662462849475, "x_impact": 0, "initial_stability_margin": 2.1844510238080797, "out_of_rail_velocity": 29.371383111827484, "lateral_surface_wind": -2.752739053828883, "max_mach_number": 0.8362318345518877, "impact_velocity": 0, "index": 32, "rail_exit_aoa": 5.3542236843905116} +{"apogee": 4591.543256316378, "y_impact": 0, "out_of_rail_time": 0.43098596986779963, "apogee_x": -164.59926204025038, "apogee_time": 25.594430559955267, "frontal_surface_wind": 0.0, "apogee_y": 573.643069690264, "out_of_rail_stability_margin": 2.537495895088278, "t_final": 25.594430559955267, "x_impact": 0, "initial_stability_margin": 2.4431530739383054, "out_of_rail_velocity": 29.381549668277213, "lateral_surface_wind": -1.8371656227090258, "max_mach_number": 0.833449675548987, "impact_velocity": 0, "index": 33, "rail_exit_aoa": 3.577924807043246} +{"apogee": 4449.513528515032, "y_impact": 0, "out_of_rail_time": 0.42799236956007586, "apogee_x": -331.81268598403165, "apogee_time": 25.102353387705485, "frontal_surface_wind": 0.0, "apogee_y": 550.1555357605414, "out_of_rail_stability_margin": 2.510459179419721, "t_final": 25.102353387705485, "x_impact": 0, "initial_stability_margin": 2.4162209096674974, "out_of_rail_velocity": 29.4112174318883, "lateral_surface_wind": -3.9826095800054295, "max_mach_number": 0.8067164076455079, "impact_velocity": 0, "index": 34, "rail_exit_aoa": 7.711587387887622} +{"apogee": 4837.302581146948, "y_impact": 0, "out_of_rail_time": 0.4099002308566802, "apogee_x": -393.78373338391094, "apogee_time": 26.27823016170023, "frontal_surface_wind": 0.0, "apogee_y": 603.7385909500493, "out_of_rail_stability_margin": 2.4102424977764154, "t_final": 26.27823016170023, "x_impact": 0, "initial_stability_margin": 2.3185258597748932, "out_of_rail_velocity": 30.99890312578654, "lateral_surface_wind": -4.5283202614085045, "max_mach_number": 0.891228096198128, "impact_velocity": 0, "index": 35, "rail_exit_aoa": 8.310984021386785} +{"apogee": 4356.597991535161, "y_impact": 0, "out_of_rail_time": 0.42217434135183507, "apogee_x": -137.58720792643825, "apogee_time": 24.66169650142705, "frontal_surface_wind": 0.0, "apogee_y": 529.8583710629215, "out_of_rail_stability_margin": 2.8752846707153, "t_final": 24.66169650142705, "x_impact": 0, "initial_stability_margin": 2.768237541940302, "out_of_rail_velocity": 29.699461942848057, "lateral_surface_wind": -1.6730859367607978, "max_mach_number": 0.7962629958085592, "impact_velocity": 0, "index": 36, "rail_exit_aoa": 3.224285708190031} +{"apogee": 4598.266674651531, "y_impact": 0, "out_of_rail_time": 0.42580511455812403, "apogee_x": -156.729228344003, "apogee_time": 25.570601629646877, "frontal_surface_wind": 0.0, "apogee_y": 571.5526539577704, "out_of_rail_stability_margin": 2.4421817675859767, "t_final": 25.570601629646877, "x_impact": 0, "initial_stability_margin": 2.3480931876285953, "out_of_rail_velocity": 29.690807522168654, "lateral_surface_wind": -1.7855132627364316, "max_mach_number": 0.8380206772749137, "impact_velocity": 0, "index": 37, "rail_exit_aoa": 3.44144622320467} +{"apogee": 4821.608332744584, "y_impact": 0, "out_of_rail_time": 0.40375643896057467, "apogee_x": -156.90138555787485, "apogee_time": 26.19892412377759, "frontal_surface_wind": 0.0, "apogee_y": 598.3704961418855, "out_of_rail_stability_margin": 2.3626903957238223, "t_final": 26.19892412377759, "x_impact": 0, "initial_stability_margin": 2.2701770453326153, "out_of_rail_velocity": 31.337259824861118, "lateral_surface_wind": -1.8127079305158535, "max_mach_number": 0.8874734747460986, "impact_velocity": 0, "index": 38, "rail_exit_aoa": 3.310592737039639} +{"apogee": 4618.980492319256, "y_impact": 0, "out_of_rail_time": 0.42867255071850185, "apogee_x": -142.64667755769077, "apogee_time": 25.696828135311875, "frontal_surface_wind": 0.0, "apogee_y": 577.11178470503, "out_of_rail_stability_margin": 2.2525603116473687, "t_final": 25.696828135311875, "x_impact": 0, "initial_stability_margin": 2.1636833521040995, "out_of_rail_velocity": 29.52867074997482, "lateral_surface_wind": -1.611859763460885, "max_mach_number": 0.8372754658894587, "impact_velocity": 0, "index": 39, "rail_exit_aoa": 3.1244616266080314} +{"apogee": 4650.496111505198, "y_impact": 0, "out_of_rail_time": 0.42753561706282917, "apogee_x": -240.21161401212666, "apogee_time": 25.82628456437363, "frontal_surface_wind": 0.0, "apogee_y": 583.9062659207591, "out_of_rail_stability_margin": 2.459141876442102, "t_final": 25.82628456437363, "x_impact": 0, "initial_stability_margin": 2.3686968396864088, "out_of_rail_velocity": 29.604512966326972, "lateral_surface_wind": -2.697456712396421, "max_mach_number": 0.8411371034332941, "impact_velocity": 0, "index": 40, "rail_exit_aoa": 5.206209392098499} +{"apogee": 4535.808409485871, "y_impact": 0, "out_of_rail_time": 0.4163973196401238, "apogee_x": -150.78785823473393, "apogee_time": 25.326036945625642, "frontal_surface_wind": 0.0, "apogee_y": 556.7760481670839, "out_of_rail_stability_margin": 1.95893017261229, "t_final": 25.326036945625642, "x_impact": 0, "initial_stability_margin": 1.8718693459619884, "out_of_rail_velocity": 30.21312137369453, "lateral_surface_wind": -1.8787934399600772, "max_mach_number": 0.8265311516058946, "impact_velocity": 0, "index": 41, "rail_exit_aoa": 3.558338108611282} +{"apogee": 4651.700159024707, "y_impact": 0, "out_of_rail_time": 0.42058202479977963, "apogee_x": -326.00725118805383, "apogee_time": 25.61847706229021, "frontal_surface_wind": 0.0, "apogee_y": 573.0573834859069, "out_of_rail_stability_margin": 2.1067889595707645, "t_final": 25.61847706229021, "x_impact": 0, "initial_stability_margin": 2.0149418257826, "out_of_rail_velocity": 30.13932316016667, "lateral_surface_wind": -3.9006500959622246, "max_mach_number": 0.8627006855789121, "impact_velocity": 0, "index": 42, "rail_exit_aoa": 7.374265760083016} +{"apogee": 4820.427023692357, "y_impact": 0, "out_of_rail_time": 0.40940344348556396, "apogee_x": -304.34668684167235, "apogee_time": 26.22448299415647, "frontal_surface_wind": 0.0, "apogee_y": 600.5937075088995, "out_of_rail_stability_margin": 2.2941767687917687, "t_final": 26.22448299415647, "x_impact": 0, "initial_stability_margin": 2.2039061563899076, "out_of_rail_velocity": 30.945775997719643, "lateral_surface_wind": -3.5268833402310236, "max_mach_number": 0.8870072925176842, "impact_velocity": 0, "index": 43, "rail_exit_aoa": 6.501932902035678} +{"apogee": 4145.980316117054, "y_impact": 0, "out_of_rail_time": 0.4351646694359077, "apogee_x": -283.35178652388095, "apogee_time": 24.024712250021157, "frontal_surface_wind": 0.0, "apogee_y": 501.96989068169586, "out_of_rail_stability_margin": 2.5338710192713543, "t_final": 24.024712250021157, "x_impact": 0, "initial_stability_margin": 2.436532690864459, "out_of_rail_velocity": 28.6816459719639, "lateral_surface_wind": -3.6793072444316444, "max_mach_number": 0.7479171944195236, "impact_velocity": 0, "index": 44, "rail_exit_aoa": 7.310030468017832} +{"apogee": 4793.961326808117, "y_impact": 0, "out_of_rail_time": 0.40388666340163726, "apogee_x": -189.07197691957515, "apogee_time": 26.14652935388459, "frontal_surface_wind": 0.0, "apogee_y": 595.4960741128258, "out_of_rail_stability_margin": 2.3672350918302367, "t_final": 26.14652935388459, "x_impact": 0, "initial_stability_margin": 2.2756350541494528, "out_of_rail_velocity": 31.317628430454743, "lateral_surface_wind": -2.216017357569485, "max_mach_number": 0.877891492507276, "impact_velocity": 0, "index": 45, "rail_exit_aoa": 4.047470279923347} +{"apogee": 4734.834341213674, "y_impact": 0, "out_of_rail_time": 0.4252702284189577, "apogee_x": -207.25899340639012, "apogee_time": 26.01325502727093, "frontal_surface_wind": 0.0, "apogee_y": 592.4608398392875, "out_of_rail_stability_margin": 2.2768527861625047, "t_final": 26.01325502727093, "x_impact": 0, "initial_stability_margin": 2.1866572611148523, "out_of_rail_velocity": 29.883502807775972, "lateral_surface_wind": -2.3173790924051207, "max_mach_number": 0.8667068705868156, "impact_velocity": 0, "index": 46, "rail_exit_aoa": 4.434247439145768} +{"apogee": 4831.760779870034, "y_impact": 0, "out_of_rail_time": 0.39116658072268107, "apogee_x": -223.81636204542218, "apogee_time": 26.112006687331036, "frontal_surface_wind": 0.0, "apogee_y": 592.5853218524821, "out_of_rail_stability_margin": 2.2493673180086216, "t_final": 26.112006687331036, "x_impact": 0, "initial_stability_margin": 2.1554998006899475, "out_of_rail_velocity": 32.27573207259573, "lateral_surface_wind": -2.7610166535447243, "max_mach_number": 0.8995490800678625, "impact_velocity": 0, "index": 47, "rail_exit_aoa": 4.88944458409614} +{"apogee": 4854.274626263077, "y_impact": 0, "out_of_rail_time": 0.400143490712648, "apogee_x": -230.85863632785288, "apogee_time": 26.26051976221902, "frontal_surface_wind": 0.0, "apogee_y": 601.1487800070748, "out_of_rail_stability_margin": 2.3350562700336814, "t_final": 26.26051976221902, "x_impact": 0, "initial_stability_margin": 2.242307362208066, "out_of_rail_velocity": 31.614990474421965, "lateral_surface_wind": -2.717122945458018, "max_mach_number": 0.8983745766588944, "impact_velocity": 0, "index": 48, "rail_exit_aoa": 4.9121654278566504} +{"apogee": 4426.351166491034, "y_impact": 0, "out_of_rail_time": 0.41798068690434903, "apogee_x": -222.35607119861976, "apogee_time": 24.92629207757687, "frontal_surface_wind": 0.0, "apogee_y": 539.5956021778271, "out_of_rail_stability_margin": 2.2143121403783472, "t_final": 24.92629207757687, "x_impact": 0, "initial_stability_margin": 2.121111651753317, "out_of_rail_velocity": 30.02777527296275, "lateral_surface_wind": -2.832046845984663, "max_mach_number": 0.8079022513603901, "impact_velocity": 0, "index": 49, "rail_exit_aoa": 5.387870347379748} +{"apogee": 4834.18322015885, "y_impact": 0, "out_of_rail_time": 0.4201856597315243, "apogee_x": -339.6623452127056, "apogee_time": 26.309076728423424, "frontal_surface_wind": 0.0, "apogee_y": 606.6368998238219, "out_of_rail_stability_margin": 2.3100083745056326, "t_final": 26.309076728423424, "x_impact": 0, "initial_stability_margin": 2.2203766635608004, "out_of_rail_velocity": 30.29977649571531, "lateral_surface_wind": -3.7847594523825006, "max_mach_number": 0.8890621593973166, "impact_velocity": 0, "index": 50, "rail_exit_aoa": 7.119965761390784} +{"apogee": 4652.297118427853, "y_impact": 0, "out_of_rail_time": 0.41447849608671916, "apogee_x": -409.15871273199605, "apogee_time": 25.68724527526533, "frontal_surface_wind": 0.0, "apogee_y": 575.5126455501345, "out_of_rail_stability_margin": 2.2390014933050937, "t_final": 25.68724527526533, "x_impact": 0, "initial_stability_margin": 2.148507857782118, "out_of_rail_velocity": 30.472037913352366, "lateral_surface_wind": -4.939108552843822, "max_mach_number": 0.8546050082718321, "impact_velocity": 0, "index": 51, "rail_exit_aoa": 9.20680717182765} +{"apogee": 4785.6085061120375, "y_impact": 0, "out_of_rail_time": 0.4265447728716474, "apogee_x": -143.45191695185548, "apogee_time": 26.22778212794182, "frontal_surface_wind": 0.0, "apogee_y": 603.0194413390082, "out_of_rail_stability_margin": 2.3569193556387438, "t_final": 26.22778212794182, "x_impact": 0, "initial_stability_margin": 2.267455802566362, "out_of_rail_velocity": 29.834304247010564, "lateral_surface_wind": -1.5406691924135483, "max_mach_number": 0.8716143978545347, "impact_velocity": 0, "index": 52, "rail_exit_aoa": 2.9561774930409843} +{"apogee": 4895.201173604007, "y_impact": 0, "out_of_rail_time": 0.40034537297433637, "apogee_x": -259.42811771167015, "apogee_time": 26.473134542228383, "frontal_surface_wind": 0.0, "apogee_y": 611.4715351677291, "out_of_rail_stability_margin": 2.4648887952276755, "t_final": 26.473134542228383, "x_impact": 0, "initial_stability_margin": 2.373479368804944, "out_of_rail_velocity": 31.59741635744488, "lateral_surface_wind": -2.9850374899423, "max_mach_number": 0.897621569036305, "impact_velocity": 0, "index": 53, "rail_exit_aoa": 5.396768984292379} +{"apogee": 4796.2173874866985, "y_impact": 0, "out_of_rail_time": 0.4130777831480599, "apogee_x": -238.88689560386788, "apogee_time": 26.247503228177905, "frontal_surface_wind": 0.0, "apogee_y": 602.2077938554493, "out_of_rail_stability_margin": 2.5263333790505773, "t_final": 26.247503228177905, "x_impact": 0, "initial_stability_margin": 2.435287522972921, "out_of_rail_velocity": 30.658022083187014, "lateral_surface_wind": -2.6857603168098736, "max_mach_number": 0.8714549217765806, "impact_velocity": 0, "index": 54, "rail_exit_aoa": 5.006548664608852} +{"apogee": 4747.272931864136, "y_impact": 0, "out_of_rail_time": 0.4149034831696583, "apogee_x": -465.69195587359496, "apogee_time": 25.999423722427927, "frontal_surface_wind": 0.0, "apogee_y": 591.0752499098882, "out_of_rail_stability_margin": 2.303246005416549, "t_final": 25.999423722427927, "x_impact": 0, "initial_stability_margin": 2.2129862616242537, "out_of_rail_velocity": 30.532962019937187, "lateral_surface_wind": -5.435706524140041, "max_mach_number": 0.8745303584339066, "impact_velocity": 0, "index": 55, "rail_exit_aoa": 10.094466393075681} +{"apogee": 4890.265738231196, "y_impact": 0, "out_of_rail_time": 0.3934965627109948, "apogee_x": -340.20014581604767, "apogee_time": 26.345716470342122, "frontal_surface_wind": 0.0, "apogee_y": 604.792102619259, "out_of_rail_stability_margin": 2.4485178734045547, "t_final": 26.345716470342122, "x_impact": 0, "initial_stability_margin": 2.353951060390061, "out_of_rail_velocity": 32.178944462208264, "lateral_surface_wind": -4.069793611933381, "max_mach_number": 0.9076105768097836, "impact_velocity": 0, "index": 56, "rail_exit_aoa": 7.208145011749926} +{"apogee": 4773.569579806192, "y_impact": 0, "out_of_rail_time": 0.3920298780503988, "apogee_x": -411.6075817749728, "apogee_time": 25.94702346229824, "frontal_surface_wind": 0.0, "apogee_y": 586.2062979704664, "out_of_rail_stability_margin": 2.6294660348157595, "t_final": 25.94702346229824, "x_impact": 0, "initial_stability_margin": 2.5303062689301443, "out_of_rail_velocity": 32.12482903001046, "lateral_surface_wind": -5.061698859231189, "max_mach_number": 0.88676563496281, "impact_velocity": 0, "index": 57, "rail_exit_aoa": 8.954105982558708} +{"apogee": 4630.486020788806, "y_impact": 0, "out_of_rail_time": 0.4193704267896021, "apogee_x": -263.45204783393723, "apogee_time": 25.640021204113303, "frontal_surface_wind": 0.0, "apogee_y": 573.5328440313372, "out_of_rail_stability_margin": 2.2457477644033665, "t_final": 25.640021204113303, "x_impact": 0, "initial_stability_margin": 2.1546601949778905, "out_of_rail_velocity": 30.143711318474217, "lateral_surface_wind": -3.1275752343049703, "max_mach_number": 0.8474238599054356, "impact_velocity": 0, "index": 58, "rail_exit_aoa": 5.923555692638457} +{"apogee": 4673.66223038333, "y_impact": 0, "out_of_rail_time": 0.4140490308301805, "apogee_x": -187.15759704616318, "apogee_time": 25.786384470771388, "frontal_surface_wind": 0.0, "apogee_y": 580.4408109132213, "out_of_rail_stability_margin": 2.629405398425624, "t_final": 25.786384470771388, "x_impact": 0, "initial_stability_margin": 2.532820812526416, "out_of_rail_velocity": 30.522791562709155, "lateral_surface_wind": -2.1577483312469523, "max_mach_number": 0.8530406191170431, "impact_velocity": 0, "index": 59, "rail_exit_aoa": 4.043684563443348} +{"apogee": 4571.445447960524, "y_impact": 0, "out_of_rail_time": 0.4229648772939671, "apogee_x": -328.673984572755, "apogee_time": 25.500249273026764, "frontal_surface_wind": 0.0, "apogee_y": 565.5477108725754, "out_of_rail_stability_margin": 1.836103990258003, "t_final": 25.500249273026764, "x_impact": 0, "initial_stability_margin": 1.7532248457586754, "out_of_rail_velocity": 29.87231578453399, "lateral_surface_wind": -4.084523254166786, "max_mach_number": 0.8318082464447516, "impact_velocity": 0, "index": 60, "rail_exit_aoa": 7.7859263744226785} +{"apogee": 4883.912678698289, "y_impact": 0, "out_of_rail_time": 0.42527539018494753, "apogee_x": -203.4951845654452, "apogee_time": 26.51001216457813, "frontal_surface_wind": 0.0, "apogee_y": 616.5678121483355, "out_of_rail_stability_margin": 2.19332166218488, "t_final": 26.51001216457813, "x_impact": 0, "initial_stability_margin": 2.1066131884949004, "out_of_rail_velocity": 30.01565862367333, "lateral_surface_wind": -2.1890902317273966, "max_mach_number": 0.8951580822818812, "impact_velocity": 0, "index": 61, "rail_exit_aoa": 4.171288039672103} +{"apogee": 4680.6386194684555, "y_impact": 0, "out_of_rail_time": 0.42346561989111414, "apogee_x": -213.6757461592623, "apogee_time": 25.860933961062617, "frontal_surface_wind": 0.0, "apogee_y": 584.3596747441185, "out_of_rail_stability_margin": 2.2211586250631683, "t_final": 25.860933961062617, "x_impact": 0, "initial_stability_margin": 2.1324737629214217, "out_of_rail_velocity": 29.9164865795034, "lateral_surface_wind": -2.4511700095185143, "max_mach_number": 0.8526650753097205, "impact_velocity": 0, "index": 62, "rail_exit_aoa": 4.683995481620223} +{"apogee": 4654.584073881658, "y_impact": 0, "out_of_rail_time": 0.42197158910714094, "apogee_x": -242.91858792349254, "apogee_time": 25.842188309592387, "frontal_surface_wind": 0.0, "apogee_y": 584.2072994623683, "out_of_rail_stability_margin": 2.6570192174367238, "t_final": 25.842188309592387, "x_impact": 0, "initial_stability_margin": 2.564319977889451, "out_of_rail_velocity": 29.924337750361353, "lateral_surface_wind": -2.73759156213601, "max_mach_number": 0.8398496515983702, "impact_velocity": 0, "index": 63, "rail_exit_aoa": 5.22708463433912} +{"apogee": 4845.202488170467, "y_impact": 0, "out_of_rail_time": 0.4032842298385252, "apogee_x": -325.41542847765544, "apogee_time": 26.250542551627863, "frontal_surface_wind": 0.0, "apogee_y": 601.8619038045018, "out_of_rail_stability_margin": 2.52566497801074, "t_final": 26.250542551627863, "x_impact": 0, "initial_stability_margin": 2.4305533119715874, "out_of_rail_velocity": 31.414486355880033, "lateral_surface_wind": -3.7767753503625583, "max_mach_number": 0.8959361870111318, "impact_velocity": 0, "index": 64, "rail_exit_aoa": 6.85542539863223} +{"apogee": 4797.3365243670805, "y_impact": 0, "out_of_rail_time": 0.40315471484650056, "apogee_x": -365.0617250055372, "apogee_time": 26.141353092076642, "frontal_surface_wind": 0.0, "apogee_y": 595.5059413526708, "out_of_rail_stability_margin": 2.3060375941495908, "t_final": 26.141353092076642, "x_impact": 0, "initial_stability_margin": 2.2156432867982105, "out_of_rail_velocity": 31.365784232551214, "lateral_surface_wind": -4.367414648075711, "max_mach_number": 0.8817536650911723, "impact_velocity": 0, "index": 65, "rail_exit_aoa": 7.926975011924114} +{"apogee": 4515.86055823126, "y_impact": 0, "out_of_rail_time": 0.41784592504968693, "apogee_x": -373.02492327064255, "apogee_time": 25.218258677427528, "frontal_surface_wind": 0.0, "apogee_y": 554.2082237592166, "out_of_rail_stability_margin": 2.359953441607795, "t_final": 25.218258677427528, "x_impact": 0, "initial_stability_margin": 2.2652193518916537, "out_of_rail_velocity": 30.12217094433833, "lateral_surface_wind": -4.6027259470210895, "max_mach_number": 0.8288215099613113, "impact_velocity": 0, "index": 66, "rail_exit_aoa": 8.687707002679995} +{"apogee": 4452.264141932459, "y_impact": 0, "out_of_rail_time": 0.4371560102491331, "apogee_x": -239.14973306732995, "apogee_time": 25.16208646969738, "frontal_surface_wind": 0.0, "apogee_y": 553.734456462753, "out_of_rail_stability_margin": 2.4052770636242715, "t_final": 25.16208646969738, "x_impact": 0, "initial_stability_margin": 2.3132679791084514, "out_of_rail_velocity": 28.869450128251874, "lateral_surface_wind": -2.784750712626476, "max_mach_number": 0.8042176316339115, "impact_velocity": 0, "index": 67, "rail_exit_aoa": 5.509711666184054} +{"apogee": 4642.672254397237, "y_impact": 0, "out_of_rail_time": 0.407821875544885, "apogee_x": -288.3492828926142, "apogee_time": 25.588976410139377, "frontal_surface_wind": 0.0, "apogee_y": 570.22524212112, "out_of_rail_stability_margin": 2.3921951200472824, "t_final": 25.588976410139377, "x_impact": 0, "initial_stability_margin": 2.296371476600632, "out_of_rail_velocity": 30.909947527965034, "lateral_surface_wind": -3.523975079954791, "max_mach_number": 0.85611783776149, "impact_velocity": 0, "index": 68, "rail_exit_aoa": 6.504083174627569} +{"apogee": 4630.274269834718, "y_impact": 0, "out_of_rail_time": 0.4183217849446811, "apogee_x": -269.47718722636114, "apogee_time": 25.629226711321788, "frontal_surface_wind": 0.0, "apogee_y": 573.5078448858428, "out_of_rail_stability_margin": 2.4298052677914375, "t_final": 25.629226711321788, "x_impact": 0, "initial_stability_margin": 2.3354291001308796, "out_of_rail_velocity": 30.203800243693497, "lateral_surface_wind": -3.16994462936623, "max_mach_number": 0.8479809857113503, "impact_velocity": 0, "index": 69, "rail_exit_aoa": 5.991364071501602} +{"apogee": 4933.910213975454, "y_impact": 0, "out_of_rail_time": 0.4054478490919374, "apogee_x": -240.12805552280278, "apogee_time": 26.507184943005182, "frontal_surface_wind": 0.0, "apogee_y": 613.8735825180578, "out_of_rail_stability_margin": 2.199772334343518, "t_final": 26.507184943005182, "x_impact": 0, "initial_stability_margin": 2.1097247467011564, "out_of_rail_velocity": 31.386709285550797, "lateral_surface_wind": -2.7481470164109507, "max_mach_number": 0.9172556018114032, "impact_velocity": 0, "index": 70, "rail_exit_aoa": 5.003923509767985} +{"apogee": 4630.839535985905, "y_impact": 0, "out_of_rail_time": 0.40876709876568135, "apogee_x": -359.83616410495495, "apogee_time": 25.564511720298324, "frontal_surface_wind": 0.0, "apogee_y": 569.2604063136346, "out_of_rail_stability_margin": 2.3590455611977377, "t_final": 25.564511720298324, "x_impact": 0, "initial_stability_margin": 2.264567467019022, "out_of_rail_velocity": 30.81322711586669, "lateral_surface_wind": -4.418638491212225, "max_mach_number": 0.8532734775983278, "impact_velocity": 0, "index": 71, "rail_exit_aoa": 8.160620968235335} +{"apogee": 4729.559503369839, "y_impact": 0, "out_of_rail_time": 0.42191355602718666, "apogee_x": -252.87565029620322, "apogee_time": 26.033834316521737, "frontal_surface_wind": 0.0, "apogee_y": 592.7627969295646, "out_of_rail_stability_margin": 2.2961130562222243, "t_final": 26.033834316521737, "x_impact": 0, "initial_stability_margin": 2.2074306050782435, "out_of_rail_velocity": 30.03697381624711, "lateral_surface_wind": -2.865535777823969, "max_mach_number": 0.8609517205056275, "impact_velocity": 0, "index": 72, "rail_exit_aoa": 5.4495409878393755} +{"apogee": 4619.104576895596, "y_impact": 0, "out_of_rail_time": 0.41433170801236874, "apogee_x": -401.75287006430256, "apogee_time": 25.551093250080996, "frontal_surface_wind": 0.0, "apogee_y": 568.0408496625219, "out_of_rail_stability_margin": 1.9787943156296002, "t_final": 25.551093250080996, "x_impact": 0, "initial_stability_margin": 1.8910961740396202, "out_of_rail_velocity": 30.49713671268091, "lateral_surface_wind": -5.024243752582158, "max_mach_number": 0.8507830468054751, "impact_velocity": 0, "index": 73, "rail_exit_aoa": 9.355148449069723} +{"apogee": 4464.449626198876, "y_impact": 0, "out_of_rail_time": 0.4282509838541082, "apogee_x": -305.00217370204007, "apogee_time": 25.154589506471474, "frontal_surface_wind": 0.0, "apogee_y": 552.6614038028171, "out_of_rail_stability_margin": 2.5482215690667944, "t_final": 25.154589506471474, "x_impact": 0, "initial_stability_margin": 2.4533070290347396, "out_of_rail_velocity": 29.41817161946509, "lateral_surface_wind": -3.62792351685249, "max_mach_number": 0.8093198990804412, "impact_velocity": 0, "index": 74, "rail_exit_aoa": 7.030363903424994} +{"apogee": 4786.889043450874, "y_impact": 0, "out_of_rail_time": 0.4081305046542291, "apogee_x": -383.40517145771287, "apogee_time": 26.088740850713556, "frontal_surface_wind": 0.0, "apogee_y": 594.0216279765633, "out_of_rail_stability_margin": 2.267542200060266, "t_final": 26.088740850713556, "x_impact": 0, "initial_stability_margin": 2.176792564767675, "out_of_rail_velocity": 31.039962212121225, "lateral_surface_wind": -4.53834736344094, "max_mach_number": 0.8836581758956217, "impact_velocity": 0, "index": 75, "rail_exit_aoa": 8.318265860212493} +{"apogee": 4648.514242662916, "y_impact": 0, "out_of_rail_time": 0.40057573090379445, "apogee_x": -235.06514960809167, "apogee_time": 25.539103391785982, "frontal_surface_wind": 0.0, "apogee_y": 567.3186085303394, "out_of_rail_stability_margin": 2.51003431882791, "t_final": 25.539103391785982, "x_impact": 0, "initial_stability_margin": 2.409827933242639, "out_of_rail_velocity": 31.413859735578118, "lateral_surface_wind": -2.9121468891932714, "max_mach_number": 0.86198407110919, "impact_velocity": 0, "index": 76, "rail_exit_aoa": 5.2963312756975} +{"apogee": 4854.052779032079, "y_impact": 0, "out_of_rail_time": 0.4131119899597909, "apogee_x": -232.95865758870437, "apogee_time": 26.363295069325037, "frontal_surface_wind": 0.0, "apogee_y": 607.3815113485223, "out_of_rail_stability_margin": 2.19609360646836, "t_final": 26.363295069325037, "x_impact": 0, "initial_stability_margin": 2.1083063628990484, "out_of_rail_velocity": 30.72555897465883, "lateral_surface_wind": -2.6416342447297434, "max_mach_number": 0.8911180907889562, "impact_velocity": 0, "index": 77, "rail_exit_aoa": 4.913928987475554} +{"apogee": 4655.630981100524, "y_impact": 0, "out_of_rail_time": 0.413441706653819, "apogee_x": -354.95116914102425, "apogee_time": 25.655538075963552, "frontal_surface_wind": 0.0, "apogee_y": 573.5775376982995, "out_of_rail_stability_margin": 2.1398671348728024, "t_final": 25.655538075963552, "x_impact": 0, "initial_stability_margin": 2.049127887006423, "out_of_rail_velocity": 30.565934616971663, "lateral_surface_wind": -4.331209637870097, "max_mach_number": 0.8589731874910751, "impact_velocity": 0, "index": 78, "rail_exit_aoa": 8.065149133447521} +{"apogee": 4645.057159870632, "y_impact": 0, "out_of_rail_time": 0.4283040827485579, "apogee_x": -296.82297503290675, "apogee_time": 25.78281127913727, "frontal_surface_wind": 0.0, "apogee_y": 581.8693490013206, "out_of_rail_stability_margin": 2.342315702950087, "t_final": 25.78281127913727, "x_impact": 0, "initial_stability_margin": 2.25274427983248, "out_of_rail_velocity": 29.578015595798007, "lateral_surface_wind": -3.37653377772554, "max_mach_number": 0.8435286096897919, "impact_velocity": 0, "index": 79, "rail_exit_aoa": 6.512514774385579} +{"apogee": 4676.31975657416, "y_impact": 0, "out_of_rail_time": 0.414745721355066, "apogee_x": -170.78928890419735, "apogee_time": 25.752324254472025, "frontal_surface_wind": 0.0, "apogee_y": 578.9315661014036, "out_of_rail_stability_margin": 2.4844817515235142, "t_final": 25.752324254472025, "x_impact": 0, "initial_stability_margin": 2.3885202525905047, "out_of_rail_velocity": 30.46923731517052, "lateral_surface_wind": -1.9752485543126, "max_mach_number": 0.8585290555805316, "impact_velocity": 0, "index": 80, "rail_exit_aoa": 3.7091595492216594} +{"apogee": 4443.227568486534, "y_impact": 0, "out_of_rail_time": 0.41934599670728845, "apogee_x": -340.6474575960493, "apogee_time": 24.955585818303675, "frontal_surface_wind": 0.0, "apogee_y": 540.2141581711332, "out_of_rail_stability_margin": 1.8260805464483307, "t_final": 24.955585818303675, "x_impact": 0, "initial_stability_margin": 1.738663017332515, "out_of_rail_velocity": 29.97506883273719, "lateral_surface_wind": -4.479170396678586, "max_mach_number": 0.8164846525252969, "impact_velocity": 0, "index": 81, "rail_exit_aoa": 8.498815258035464} +{"apogee": 4663.511074111039, "y_impact": 0, "out_of_rail_time": 0.4312201878917248, "apogee_x": -248.79773283137757, "apogee_time": 25.885237636254082, "frontal_surface_wind": 0.0, "apogee_y": 585.8733313409764, "out_of_rail_stability_margin": 2.04501562865208, "t_final": 25.885237636254082, "x_impact": 0, "initial_stability_margin": 1.9613266844377832, "out_of_rail_velocity": 29.4102349205895, "lateral_surface_wind": -2.840053566569036, "max_mach_number": 0.8437600711798128, "impact_velocity": 0, "index": 82, "rail_exit_aoa": 5.515769891033272} +{"apogee": 4761.477268827097, "y_impact": 0, "out_of_rail_time": 0.4218730746715751, "apogee_x": -159.6636353582206, "apogee_time": 26.0246989253644, "frontal_surface_wind": 0.0, "apogee_y": 593.3422274466111, "out_of_rail_stability_margin": 2.432448256679382, "t_final": 26.0246989253644, "x_impact": 0, "initial_stability_margin": 2.3374542731583414, "out_of_rail_velocity": 30.138809095326163, "lateral_surface_wind": -1.7619552266876675, "max_mach_number": 0.878696446451948, "impact_velocity": 0, "index": 83, "rail_exit_aoa": 3.3457799744341457} +{"apogee": 4756.780011823031, "y_impact": 0, "out_of_rail_time": 0.41463209318284705, "apogee_x": -391.6103808349012, "apogee_time": 26.04655575335157, "frontal_surface_wind": 0.0, "apogee_y": 593.3468530093837, "out_of_rail_stability_margin": 2.4312433448433732, "t_final": 26.04655575335157, "x_impact": 0, "initial_stability_margin": 2.339144463832044, "out_of_rail_velocity": 30.557445295324, "lateral_surface_wind": -4.512622295575593, "max_mach_number": 0.8734807276171523, "impact_velocity": 0, "index": 84, "rail_exit_aoa": 8.400534642442093} +{"apogee": 4429.459505186919, "y_impact": 0, "out_of_rail_time": 0.4272035964383403, "apogee_x": -253.98505545147447, "apogee_time": 25.021660183751564, "frontal_surface_wind": 0.0, "apogee_y": 545.9298899996932, "out_of_rail_stability_margin": 2.4629938949733345, "t_final": 25.021660183751564, "x_impact": 0, "initial_stability_margin": 2.3685489783687186, "out_of_rail_velocity": 29.452574052939138, "lateral_surface_wind": -3.0765092736178596, "max_mach_number": 0.8028549321466105, "impact_velocity": 0, "index": 85, "rail_exit_aoa": 5.963283745711275} +{"apogee": 4688.733827024327, "y_impact": 0, "out_of_rail_time": 0.4193393510392021, "apogee_x": -416.8090169401243, "apogee_time": 25.853007890697175, "frontal_surface_wind": 0.0, "apogee_y": 583.8238387629679, "out_of_rail_stability_margin": 2.16764044911626, "t_final": 25.853007890697175, "x_impact": 0, "initial_stability_margin": 2.0799051415437715, "out_of_rail_velocity": 30.195243344584497, "lateral_surface_wind": -4.917189659068784, "max_mach_number": 0.858814023217045, "impact_velocity": 0, "index": 86, "rail_exit_aoa": 9.24922734677589} +{"apogee": 4747.7344106012115, "y_impact": 0, "out_of_rail_time": 0.41959495387868145, "apogee_x": -135.66498848642044, "apogee_time": 26.082485411340826, "frontal_surface_wind": 0.0, "apogee_y": 593.7767050883701, "out_of_rail_stability_margin": 2.1005835753869038, "t_final": 26.082485411340826, "x_impact": 0, "initial_stability_margin": 2.0146511219153527, "out_of_rail_velocity": 30.2134380158144, "lateral_surface_wind": -1.5340764292333717, "max_mach_number": 0.8643832802677351, "impact_velocity": 0, "index": 87, "rail_exit_aoa": 2.9066764098384854} +{"apogee": 4889.618418430485, "y_impact": 0, "out_of_rail_time": 0.4072382747651859, "apogee_x": -68.20537337908166, "apogee_time": 26.41155582469309, "frontal_surface_wind": 0.0, "apogee_y": 608.8890741298794, "out_of_rail_stability_margin": 2.2183906286609774, "t_final": 26.41155582469309, "x_impact": 0, "initial_stability_margin": 2.1285436536978732, "out_of_rail_velocity": 31.17668583031003, "lateral_surface_wind": -0.730532829629163, "max_mach_number": 0.9028334064122084, "impact_velocity": 0, "index": 88, "rail_exit_aoa": 1.3423104085146087} +{"apogee": 4472.197617047766, "y_impact": 0, "out_of_rail_time": 0.42558749217289993, "apogee_x": -129.2416707397595, "apogee_time": 25.08473823250677, "frontal_surface_wind": 0.0, "apogee_y": 549.1430596772685, "out_of_rail_stability_margin": 2.557759315419103, "t_final": 25.08473823250677, "x_impact": 0, "initial_stability_margin": 2.457903161928682, "out_of_rail_velocity": 29.60199583617497, "lateral_surface_wind": -1.5166883946263867, "max_mach_number": 0.8184019625613047, "impact_velocity": 0, "index": 89, "rail_exit_aoa": 2.9330428515556783} +{"apogee": 4793.976685935666, "y_impact": 0, "out_of_rail_time": 0.4083968521446543, "apogee_x": -338.32525713683754, "apogee_time": 26.064603853402183, "frontal_surface_wind": 0.0, "apogee_y": 592.8314476978112, "out_of_rail_stability_margin": 2.1735535612528336, "t_final": 26.064603853402183, "x_impact": 0, "initial_stability_margin": 2.082565248472076, "out_of_rail_velocity": 31.039201817991586, "lateral_surface_wind": -4.022053907720315, "max_mach_number": 0.890055150609898, "impact_velocity": 0, "index": 90, "rail_exit_aoa": 7.383235913021106} +{"apogee": 4506.059541269961, "y_impact": 0, "out_of_rail_time": 0.3989202535288424, "apogee_x": -175.9870880218369, "apogee_time": 25.073386540897808, "frontal_surface_wind": 0.0, "apogee_y": 544.4417340423548, "out_of_rail_stability_margin": 2.311089031798174, "t_final": 25.073386540897808, "x_impact": 0, "initial_stability_margin": 2.213184310897127, "out_of_rail_velocity": 31.347122256144516, "lateral_surface_wind": -2.307053021883749, "max_mach_number": 0.8308621759869218, "impact_velocity": 0, "index": 91, "rail_exit_aoa": 4.209206615810742} +{"apogee": 4542.574031475685, "y_impact": 0, "out_of_rail_time": 0.4280751114841993, "apogee_x": -433.7461007739593, "apogee_time": 25.3986161545091, "frontal_surface_wind": 0.0, "apogee_y": 563.7262483408686, "out_of_rail_stability_margin": 2.2347389134335, "t_final": 25.3986161545091, "x_impact": 0, "initial_stability_margin": 2.1452617762886614, "out_of_rail_velocity": 29.518523144283357, "lateral_surface_wind": -5.1703570630209255, "max_mach_number": 0.8290011565159854, "impact_velocity": 0, "index": 92, "rail_exit_aoa": 9.934937827680258} +{"apogee": 4436.31865658282, "y_impact": 0, "out_of_rail_time": 0.4227964863859661, "apogee_x": -216.31902752858005, "apogee_time": 24.951481544263846, "frontal_surface_wind": 0.0, "apogee_y": 541.7342231588866, "out_of_rail_stability_margin": 2.2513147461829437, "t_final": 24.951481544263846, "x_impact": 0, "initial_stability_margin": 2.1564806530154996, "out_of_rail_velocity": 29.747077995369374, "lateral_surface_wind": -2.6938106711729386, "max_mach_number": 0.8122060668266222, "impact_velocity": 0, "index": 93, "rail_exit_aoa": 5.17442895799046} +{"apogee": 4659.360055321377, "y_impact": 0, "out_of_rail_time": 0.4188550270751765, "apogee_x": -145.82130169233386, "apogee_time": 25.745112073084943, "frontal_surface_wind": 0.0, "apogee_y": 578.0255510963478, "out_of_rail_stability_margin": 2.208801488371183, "t_final": 25.745112073084943, "x_impact": 0, "initial_stability_margin": 2.1186283458897055, "out_of_rail_velocity": 30.1986116012157, "lateral_surface_wind": -1.6933181953563228, "max_mach_number": 0.851260418988824, "impact_velocity": 0, "index": 94, "rail_exit_aoa": 3.2093692536625387} +{"apogee": 4814.353149917056, "y_impact": 0, "out_of_rail_time": 0.41415801524290713, "apogee_x": -357.61666771012887, "apogee_time": 26.210984717724337, "frontal_surface_wind": 0.0, "apogee_y": 599.9051789053665, "out_of_rail_stability_margin": 2.021343837636815, "t_final": 26.210984717724337, "x_impact": 0, "initial_stability_margin": 1.9354428822403793, "out_of_rail_velocity": 30.664927116715944, "lateral_surface_wind": -4.188197821482612, "max_mach_number": 0.8875065880773171, "impact_velocity": 0, "index": 95, "rail_exit_aoa": 7.777303202614337} +{"apogee": 4820.155625462501, "y_impact": 0, "out_of_rail_time": 0.42765936134071747, "apogee_x": -396.81742922898417, "apogee_time": 26.350901542916535, "frontal_surface_wind": 0.0, "apogee_y": 609.9280644485187, "out_of_rail_stability_margin": 2.3599032022126702, "t_final": 26.350901542916535, "x_impact": 0, "initial_stability_margin": 2.272009622906703, "out_of_rail_velocity": 29.794042165357798, "lateral_surface_wind": -4.310573213964412, "max_mach_number": 0.8797907750376329, "impact_velocity": 0, "index": 96, "rail_exit_aoa": 8.232375051102547} +{"apogee": 4387.605007375576, "y_impact": 0, "out_of_rail_time": 0.445267353606082, "apogee_x": -163.66242657244558, "apogee_time": 24.981355133668707, "frontal_surface_wind": 0.0, "apogee_y": 546.7530770605564, "out_of_rail_stability_margin": 2.609939848517891, "t_final": 24.981355133668707, "x_impact": 0, "initial_stability_margin": 2.514748662699979, "out_of_rail_velocity": 28.386865620185336, "lateral_surface_wind": -1.8559781502330297, "max_mach_number": 0.7888298933488584, "impact_velocity": 0, "index": 97, "rail_exit_aoa": 3.7407644254544694} +{"apogee": 4748.853222703051, "y_impact": 0, "out_of_rail_time": 0.4189806478219249, "apogee_x": -245.3940428582747, "apogee_time": 26.032866106266557, "frontal_surface_wind": 0.0, "apogee_y": 592.3312016696564, "out_of_rail_stability_margin": 2.2331166516236287, "t_final": 26.032866106266557, "x_impact": 0, "initial_stability_margin": 2.143500523700404, "out_of_rail_velocity": 30.284134036215082, "lateral_surface_wind": -2.812287615837798, "max_mach_number": 0.8706871062299387, "impact_velocity": 0, "index": 98, "rail_exit_aoa": 5.305464980025932} +{"apogee": 4882.446414812261, "y_impact": 0, "out_of_rail_time": 0.4025432629153109, "apogee_x": -312.0971572085366, "apogee_time": 26.3444702601522, "frontal_surface_wind": 0.0, "apogee_y": 604.6416705409873, "out_of_rail_stability_margin": 1.9729934597236711, "t_final": 26.3444702601522, "x_impact": 0, "initial_stability_margin": 1.8864112128893116, "out_of_rail_velocity": 31.51424342853964, "lateral_surface_wind": -3.7471560654077747, "max_mach_number": 0.9067071035078418, "impact_velocity": 0, "index": 99, "rail_exit_aoa": 6.780836398344449} +{"apogee": 4575.521046801354, "y_impact": 0, "out_of_rail_time": 0.4258092526029767, "apogee_x": -333.7826668054668, "apogee_time": 25.54783967052688, "frontal_surface_wind": 0.0, "apogee_y": 569.8781411643731, "out_of_rail_stability_margin": 2.290392314150821, "t_final": 25.54783967052688, "x_impact": 0, "initial_stability_margin": 2.201419586472164, "out_of_rail_velocity": 29.695445810354602, "lateral_surface_wind": -3.94310023852307, "max_mach_number": 0.8294619360772141, "impact_velocity": 0, "index": 100, "rail_exit_aoa": 7.563754664560585} diff --git a/docs/reference/classes/Function.rst b/docs/reference/classes/Function.rst index 423123945..88a1bc769 100644 --- a/docs/reference/classes/Function.rst +++ b/docs/reference/classes/Function.rst @@ -4,4 +4,7 @@ Function Classes .. seealso:: :doc:`Function Class Usage ` .. autoclass:: rocketpy.Function + :members: + +.. autoclass:: rocketpy.PiecewiseFunction :members: \ No newline at end of file diff --git a/docs/reference/classes/PointMassRocket.rst b/docs/reference/classes/PointMassRocket.rst new file mode 100644 index 000000000..f77a8ca54 --- /dev/null +++ b/docs/reference/classes/PointMassRocket.rst @@ -0,0 +1,5 @@ +PointMassRocket Class +--------------------- + +.. autoclass:: rocketpy.PointMassRocket + :members: diff --git a/docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst b/docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst new file mode 100644 index 000000000..94e9b7814 --- /dev/null +++ b/docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst @@ -0,0 +1,5 @@ +Controllable Generic Surface Class +---------------------------------- + +.. autoclass:: rocketpy.ControllableGenericSurface + :members: diff --git a/docs/reference/classes/aero_surfaces/index.rst b/docs/reference/classes/aero_surfaces/index.rst index a3dad0417..d4b67e68a 100644 --- a/docs/reference/classes/aero_surfaces/index.rst +++ b/docs/reference/classes/aero_surfaces/index.rst @@ -19,4 +19,5 @@ AeroSurface Classes RailButtons AirBrakes GenericSurface - LinearGenericSurface \ No newline at end of file + LinearGenericSurface + ControllableGenericSurface \ No newline at end of file diff --git a/docs/reference/classes/monte_carlo/stochastic_models/index.rst b/docs/reference/classes/monte_carlo/stochastic_models/index.rst index ca8b2b1e2..028a48246 100644 --- a/docs/reference/classes/monte_carlo/stochastic_models/index.rst +++ b/docs/reference/classes/monte_carlo/stochastic_models/index.rst @@ -20,6 +20,7 @@ input parameters, enabling robust Monte Carlo simulations. stochastic_trapezoidal_fins stochastic_elliptical_fins stochastic_tail + stochastic_air_brakes stochastic_rail_buttons stochastic_rocket stochastic_parachute diff --git a/docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst b/docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst new file mode 100644 index 000000000..7f37852c0 --- /dev/null +++ b/docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst @@ -0,0 +1,5 @@ +Stochastic Air Brakes +--------------------- + +.. autoclass:: rocketpy.stochastic.StochasticAirBrakes + :members: diff --git a/docs/reference/classes/motors/EmptyMotor.rst b/docs/reference/classes/motors/EmptyMotor.rst new file mode 100644 index 000000000..757c04937 --- /dev/null +++ b/docs/reference/classes/motors/EmptyMotor.rst @@ -0,0 +1,5 @@ +EmptyMotor Class +---------------- + +.. autoclass:: rocketpy.EmptyMotor + :members: diff --git a/docs/reference/classes/motors/PointMassMotor.rst b/docs/reference/classes/motors/PointMassMotor.rst new file mode 100644 index 000000000..4635ca274 --- /dev/null +++ b/docs/reference/classes/motors/PointMassMotor.rst @@ -0,0 +1,5 @@ +PointMassMotor Class +-------------------- + +.. autoclass:: rocketpy.PointMassMotor + :members: diff --git a/docs/reference/classes/motors/index.rst b/docs/reference/classes/motors/index.rst index 3bd6e2d96..99d905295 100644 --- a/docs/reference/classes/motors/index.rst +++ b/docs/reference/classes/motors/index.rst @@ -10,6 +10,8 @@ Motor Classes HybridMotor LiquidMotor GenericMotor + PointMassMotor + EmptyMotor Fluid Tank Classes Tank Geometry Classes diff --git a/docs/reference/index.rst b/docs/reference/index.rst index e86240dd9..f5503e12e 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -13,6 +13,7 @@ This reference manual details functions, modules, methods and attributes include AeroSurface Classes classes/Components classes/Rocket + classes/PointMassRocket classes/Parachute classes/sensors/index.rst classes/Flight diff --git a/docs/static/rocket/aeroframe.png b/docs/static/rocket/aeroframe.png index c48c86e1aa4c9cbc9285e113b4cfd27d73f6acb3..235c15eb998e30839e4d9a9dacab8d0710deee2f 100644 GIT binary patch literal 44915 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 z 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 C{T-eF literal 40926 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

ug|0JwuLwJmhd%9u3#*m+%DLFzjG`Y+ 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 ScncZ3cfX;jK@PSAQsR7 diff --git a/docs/static/rocket/cal-per-length.png b/docs/static/rocket/cal-per-length.png new file mode 100644 index 0000000000000000000000000000000000000000..1d3a66ab304810d1eb8bbbf34f96c3459faaaab4 GIT binary patch literal 10274 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 literal 0 HcmV?d00001 diff --git a/docs/static/rocket/damped-oscillation.png b/docs/static/rocket/damped-oscillation.png new file mode 100644 index 0000000000000000000000000000000000000000..80acab979cc477c298ad39af41b56ca0d4e6150a GIT binary patch literal 102752 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

Zwg8$R3N5qHS0MKO8qQ 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%0A6AD98JlcWZQc}PF5N|#>}qX`drg{ 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

w$ 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 ajNPTumo?UO{~88gTQ+Z3OHnoR{J#L|446Iu literal 0 HcmV?d00001 diff --git a/docs/static/rocket/stable-unstable.png b/docs/static/rocket/stable-unstable.png new file mode 100644 index 0000000000000000000000000000000000000000..7757d139933c5b75f09ce55d35de0aa893cc46a8 GIT binary patch literal 110823 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 z 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 GyZ;BWbcr|s literal 0 HcmV?d00001 diff --git a/docs/technical/aerodynamics/center_of_pressure_and_stability.rst b/docs/technical/aerodynamics/center_of_pressure_and_stability.rst deleted file mode 100644 index 80e7de108..000000000 --- a/docs/technical/aerodynamics/center_of_pressure_and_stability.rst +++ /dev/null @@ -1,405 +0,0 @@ -.. _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 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) ------------------------------- - -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`). 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 --------------------- - -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, and the ``*_yaw`` counterparts expose the yaw plane. - -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``. - -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 -=========================== - -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 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). - * - ``Flight.stability_margin`` (``_yaw``) - - :math:`t` - - Linear margin along the realized Mach(t) — smooth. - * - ``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.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 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). - -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; 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 -========== - -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 3b1cb113d..d856c60d3 100644 --- a/docs/technical/aerodynamics/elliptical_fins.rst +++ b/docs/technical/aerodynamics/elliptical_fins.rst @@ -1,3 +1,7 @@ +========================= +Elliptical Fins Equations +========================= + Nomenclature ============ diff --git a/docs/technical/aerodynamics/roll_equations.rst b/docs/technical/aerodynamics/roll_equations.rst index b35f1de68..394d6ea02 100644 --- a/docs/technical/aerodynamics/roll_equations.rst +++ b/docs/technical/aerodynamics/roll_equations.rst @@ -1,3 +1,7 @@ +======================================= +Roll equations for high-powered rockets +======================================= + Nomenclature ============ diff --git a/docs/technical/index.rst b/docs/technical/index.rst index 050c366e0..bb49ac56c 100644 --- a/docs/technical/index.rst +++ b/docs/technical/index.rst @@ -12,7 +12,6 @@ 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/docs/user/center_of_pressure_and_stability.rst b/docs/user/center_of_pressure_and_stability.rst new file mode 100644 index 000000000..69962b581 --- /dev/null +++ b/docs/user/center_of_pressure_and_stability.rst @@ -0,0 +1,1064 @@ +.. _aero_cp_stability: + +================================ +Center of Pressure and Stability +================================ + +Introduction +============ + +Stability is a central consideration in rocket design. Two rules of thumb are +widely repeated in amateur and student rocketry: the center of pressure must be +behind the center of gravity, and a static margin of one to two calibers is +generally desirable. This document explains the reasoning behind those rules +and distinguishes between three related but distinct quantities: **static +margin**, **stability margin**, and **dynamic stability**. + +The presentation proceeds from physical principles to the exact definitions and +formulas used by RocketPy, illustrated throughout with the **Calisto** reference +rocket, introduced in the :ref:`firstsimulation` guide. + +.. contents:: On this page + :local: + :depth: 2 + +.. jupyter-execute:: + :hide-code: + :hide-output: + + # The Calisto reference rocket from the First Simulation guide, reused for + # every worked example below. See the firstsimulation guide for the full + # build. IMPORTANT: adjust the data paths below to match your own system. + from rocketpy import Environment, SolidMotor, Rocket, Flight + + env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + env.set_atmospheric_model(type="standard_atmosphere") + + Pro75M1670 = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + + rocket = Rocket( + radius=127 / 2000, + 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", + ) + rocket.add_motor(Pro75M1670, position=-1.255) + rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + cant_angle=0.0, + position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + + test_flight = Flight( + rocket=rocket, environment=env, rail_length=5.2, inclination=85, heading=0 + ) + + +Part 1: Physical principles +============================ + +Static stability +----------------- + +.. admonition:: Static stability + :class: note + + A rocket is **statically stable** if, when a disturbance pushes its nose + away from the flight direction, the aerodynamic forces generate a + restoring moment that returns it toward that direction. + + An **unstable** rocket exhibits the opposite behavior: a disturbance is + never corrected, and the rocket tumbles. + +Whether a restoring moment exists depends on the relative position of two +points along the rocket's axis: the **center of mass** and the **center of +pressure**: + +- **Center of mass (CM), also called center of gravity (CG).** The point about + which the rocket's mass is balanced, and about which the rocket rotates in + flight. In RocketPy this quantity is ``Rocket.center_of_mass``, a function of + time since propellant consumption shifts it. + +- **Center of pressure (CP).** The point at which the net aerodynamic force can + be considered to act. At a small angle to the airflow, a sideways ("normal") + force is generated, and the CP is its effective point of application. In + RocketPy this quantity is ``Rocket.cp_position``, an alias of + ``Rocket.aerodynamic_center``. + +The relative position of these two points determines stability: + +.. admonition:: The stability rule + :class: important + + If the **center of pressure is located behind the center of mass** (toward + the tail), the rocket is stable. + + If the center of pressure is located ahead of the center of mass the rocket + is unstable. + +.. figure:: ../../static/rocket/stable-unstable.png + :align: center + :width: 80% + + The aerodynamic force acting at the CP creates a torque about the CM. + When the CP is aft of the CM the torque is restoring (left); when the CP + is forward of the CM the torque grows the disturbance (right). + +.. Role of the fins +.. ----------------- + +.. Fins are the primary means of shifting the CP toward the tail. As large +.. lifting surfaces positioned far aft, they move the rocket's aggregate center +.. of pressure behind the center of mass, establishing the restoring lever arm. +.. Nose cones and outward-flaring transitions tend to move the CP forward and are +.. destabilizing in isolation; the fins must more than compensate for this +.. effect. + +.. Increasing fin size, moving the fins aft, or adding mass to the nose (which +.. moves the CM forward) are the standard corrective measures for an unstable +.. design, since each increases the distance by which the CP trails the CM. + + +Part 2: Static margin +===================== + +Definition +---------- + +.. admonition:: Static margin + :class: note + + The **static margin** is the distance between the **CM** and the **CP**, + expressed in **calibers**, where one caliber equals the body diameter. + + .. math:: + :label: static_margin_intro + + \text{static margin} = + \frac{(\text{CP position}) - (\text{CM position})}{\text{diameter}} + \;\;[\text{calibers}] + + - A **positive** static margin indicates that the CP is behind the CM, and + therefore that the rocket is stable. A margin of two calibers indicates + that the two points are separated by two body diameters. + - A **negative** static margin indicates that the CP is ahead of the CM, and + therefore that the rocket is unstable. + + The diameter is used as the reference length because it is the natural + length scale of the aerodynamics: the normal force generated by a body + scales with its cross-sectional area. This convention is widely used in + rocketry. + +Here is a plot of the static margin of the Calisto rocket over time. The +variation of the static margin here is due to the forward shift of the center of +mass as the motor burns and propellant is consumed. The right-hand axis +expresses the same margin as a percentage of body length (discussed in +:ref:`percent_of_length`): + +.. jupyter-execute:: + + rocket.plots.static_margin() + +Recommended margin range +------------------------ + +The following ranges are widely used design rules of thumb, not precise or +authoritative thresholds: + +- **Below approximately 1 caliber:** marginal. Manufacturing tolerances, added + nose-cone mass, or a shifted CG can be enough to make the rocket unstable. +- **Approximately 1 to 2 calibers:** the standard target range for most + rockets, and the most common recommendation in hobby and student rocketry. +- **Approximately 2 to 3 calibers:** another common target range for high-power + rockets, providing extra margin for the CG shift that occurs as the motor + burns. +- **Above approximately 4 calibers (over-stable):** also undesirable, for the + reasons described below. + +**These figures are rules of thumb, not objective truths**: the transition +between ranges is gradual and depends on the specific rocket, so a margin +just outside one of them does not mean the rocket will not fly safely. Falling +within a target range also does not by itself guarantee good flight behavior. + +.. admonition:: Excessive static margin + :class: warning + + An over-stable rocket has a strong restoring moment, so in a crosswind it + turns into the wind and drifts further downwind ("weathercocking"). Aim for + enough margin to keep the rocket reliably stable, rather than the largest + margin achievable. The flight studies in :ref:`stability_in_flight` examine + how much this actually affects a real flight, and the + :ref:`practical studies ` show that the altitude usually + blamed on over-stability is really the cost of the added nose weight. + +.. _percent_of_length: + +Margin as a percentage of length +-------------------------------- + +An alternative convention expresses the margin as a **percentage of the +rocket's overall length** rather than in calibers: + +.. math:: + + \text{margin (\% of length)} = + \frac{(\text{CP position}) - (\text{CM position})}{\text{body length}} + \times 100 + +.. figure:: ../../static/rocket/cal-per-length.png + :align: center + :width: 80% + + The same CM-to-CP distance expressed against two reference lengths: the + body diameter (one caliber) and the overall body length. Both numbers + describe the identical physical gap. + +This measures the identical physical distance as the caliber margin, +normalized by the total length instead of the diameter, as illustrated +above. RocketPy exposes the overall length as +:attr:`rocketpy.Rocket.length`, defined as the axial span from the nose tip +to the aft-most point of the rocket, whether that is an aerodynamic surface +or the motor nozzle if it extends further aft. + +For Calisto (from :ref:`firstsimulation`), with a length of **2.53 m** and a +fineness ratio of approximately 20, the two conventions yield: + +.. list-table:: + :header-rows: 1 + :widths: 40 30 30 + + * - Condition + - Calibers + - % of length + * - Lift-off (``t = 0``) + - ``2.20 c`` + - ``11.0 %`` + * - Burnout (``t = 3.9 s``) + - ``3.11 c`` + - ``15.6 %`` + +As a rule of thumb, a percent-of-length margin of roughly **8 to 15%** is a +commonly cited target, with the lower bound the firmer of the two. For a +typical rocket with a fineness ratio near 10, one caliber is about 10% of the +length, so this range corresponds to the familiar 1 to 2 calibers. The two +conventions only diverge for unusually short or slender rockets, which is +precisely where the percentage form is meant to help. Like the caliber ranges, +these are **informal guidelines rather than authoritative thresholds**. + +.. admonition:: Why express margin as a percent of length? + :class: note + + The percent-of-length convention fixes a **gap in the caliber measure**: the + caliber says nothing about the rocket's overall length. Two rockets with + a static margin of 2 calibers, one short and one long and slender, don't + necessarily behave the same in flight. + + The slender rocket has + **substantially greater** rotational inertia (which scales with length + squared) and a longer aerodynamic damping arm. Expressing the margin as + a fraction of length scales the required + physical margin with rocket size. This is a rough correction for + slenderness. + + +.. _stability_margin_part: + +Part 3: Stability margin +======================== + +The **stability margin** is the same center-of-mass-to-center-of-pressure +distance, in the same calibers, but with the center of pressure taken at the +rocket's **actual flight condition**, its Mach number and angle of attack, +rather than at rest. + +Writing :math:`z_\text{cm}(t)` for the center of mass, :math:`2R` for the body +diameter (one caliber), the two margins have the same form and differ only in +the center-of-pressure reference they subtract: + +.. math:: + + \text{static margin}(t) = c\,\frac{z_\text{cm}(t) - x_\text{AC}(0)}{2R}, + \qquad + \text{stability margin}(\alpha, M, t) + = c\,\frac{z_\text{cm}(t) - x_\text{NP}(\alpha, M)}{2R}. + +- The static margin uses the **aerodynamic center** :math:`x_\text{AC}(0)`: the + center of pressure linearized about zero angle of attack and evaluated at zero + airspeed (:math:`M = 0`). It is a fixed reference, so the static margin varies + only through the center of mass, that is, with time. +- The stability margin uses the **neutral point** :math:`x_\text{NP}(\alpha, M)`, + the local center of pressure at the actual Mach number and angle of attack. + Because that reference moves with the flow, the stability margin depends on + angle of attack and Mach number as well as time. + +The static margin and the stability margin are the same underlying +quantity, evaluated under different +conditions: + +.. list-table:: + :header-rows: 1 + :widths: 24 24 26 + + * - + - **Static margin** + - **Stability margin** + * - Depends on + - Time only + - Angle of attack, Mach number **and** time + * - Evaluated at + - Zero incidence, zero airspeed (``M = 0``) + - *Any* angle of attack and Mach number (an aerodynamic map) + * - Answers + - Stability at rest + - Stability at any chosen flow state + * - RocketPy + - ``Rocket.static_margin`` (function of ``t``) + - ``Rocket.stability_margin`` (function of ``alpha, M, t``) + +The margin varies for up to three independent reasons: + +**1. Center-of-mass displacement (time).** As the motor consumes propellant the +CM moves. This changes the CM-to-CP distance. + +**2. Center-of-pressure displacement (Mach number).** Aerodynamic surfaces +effectivenss (e.g. fins) varies with speed, causing the CP +to shift with Mach number. + +**3. Center-of-pressure displacement (angle of attack).** For a rocket +carrying a surface that generates lift nonlinearly with incidence, the center +of pressure also migrates with the angle of attack. RocketPy's pre-set +geometric surfaces (``NoseCone``, ``TrapezoidalFins``, +``EllipticalFins``, ``FreeFormFins``, ``Tail``) are all linear in +incidence, so a rocket built only from them never sees this effect. It shows up +when a surface is added as a :class:`rocketpy.GenericSurface` with a nonlinear +coefficient, for example a Galejs body-lift term. + +The flight stability margin +---------------------------- + +``Flight.stability_margin`` samples the rocket's ``stability_margin`` map at +the angle of attack, Mach number and time realized during the simulated flight: + +.. jupyter-execute:: + + test_flight.prints.stability_margin() + +``Flight.plots.stability_margin_data()`` plots this curve for the pitch and yaw +planes, with a percent-of-length axis on the right: + +.. jupyter-execute:: + + test_flight.plots.stability_margin_data() + +.. tip:: + + The **out-of-rail stability margin** is frequently the most significant + single value: it is the margin at the instant of rail departure, when the + rocket is slowest and most susceptible to wind disturbance. A recommended + design practice is to ensure this value is suficcient. The out-of-rail + instant is examined in detail in :ref:`stability_in_flight`. + + +.. _part_dynamic: + +Part 4: Dynamic stability +========================= + +The static margin has a fundamental limitation: it indicates only the *sign* +of the restoring moment, not the rocket's actual dynamic behavior. A positive +margin guarantees the existence of a restoring tendency but provides no +information regarding the rate of return, the presence of overshoot, or +whether resulting oscillations decay or persist. These characteristics are +described by **dynamic stability**. + +.. jupyter-execute:: + :hide-code: + :hide-output: + + import numpy as np + import matplotlib.pyplot as plt + + def build_rocket(center_of_mass_without_motor=0.0, lateral_inertia=6.321, + mass=14.426, motor=Pro75M1670): + """Calisto with four adjustable parameters. Shifting the dry center of + mass toward the nose (positive) raises the static margin; toward the tail + (negative) lowers it. The lateral moment of inertia is adjustable + separately, which changes the dynamic response without changing the + static margin. The dry mass (in kilograms) and the motor can also be + swapped, for the mass and thrust studies later in this document. + Everything else, including the aerodynamics, is fixed. + """ + rocket = Rocket( + radius=127 / 2000, + mass=mass, + inertia=(lateral_inertia, lateral_inertia, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=center_of_mass_without_motor, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(motor, position=-1.255) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.060, span=0.110, + cant_angle=0.0, position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + return rocket + + def windy_site(wind_speed): + """A standard atmosphere with a constant eastward crosswind, in m/s.""" + site = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + site.set_atmospheric_model( + type="custom_atmosphere", wind_u=wind_speed, wind_v=0 + ) + return site + +The attitude oscillator +----------------------- + +A stable rocket disturbed by angle :math:`\theta` behaves as a damped +spring-mass oscillator: + +.. math:: + + I_L\,\ddot\theta + C_2\,\dot\theta + C_1\,\theta = 0 + +with three governing parameters: + +- :math:`C_1`, the **corrective (restoring) moment coefficient**, analogous + to a spring constant. It is proportional to the static margin: + :math:`C_1 = \bar q\, A\, C_{N,\alpha}\, (z_\text{cm} - x_\text{cp})`, where + :math:`\bar q` is the dynamic pressure. A larger margin or higher airspeed + produces a stiffer restoring moment. +- :math:`C_2`, the **damping moment coefficient**, analogous to a damping + coefficient. It arises from aerodynamic resistance of the fins to rotation, + together with **jet damping** resulting from mass ejection through the + nozzle. +- :math:`I_L`, the **lateral moment of inertia** about the center of mass, + representing the rotational inertia opposing angular acceleration. + +These parameters determine the two quantities that characterize the dynamic +response: + +.. math:: + + \omega_n = \sqrt{\frac{C_1}{I_L}} + \quad(\text{natural frequency}), + \qquad + \zeta = \frac{C_2}{2\sqrt{C_1\,I_L}} + \quad(\text{damping ratio}). + +- The **natural frequency** :math:`\omega_n` sets the oscillation rate, in + rad/s (divide by :math:`2\pi` for Hz), and increases with static margin and + airspeed. +- The **damping ratio** :math:`\zeta` sets how quickly the oscillation + decays. Rockets are normally *underdamped* (:math:`\zeta < 1`), oscillating + with the amplitude shrinking over several cycles. + +.. figure:: ../../static/rocket/damped-oscillation.png + :align: center + :width: 90% + + Attitude response to a single disturbance. The spacing between peaks is set + by the natural frequency; the rate at which the peaks shrink toward zero, the + dashed envelope, is set by the damping ratio. A larger damping ratio decays + faster for the same natural frequency. + +RocketPy exposes each of these quantities on the ``Flight`` object: +``corrective_moment_coefficient`` (:math:`C_1`), +``damping_moment_coefficient`` (:math:`C_2`), +``pitch_natural_frequency``, ``pitch_damping_ratio``, and the corresponding +``yaw_*`` quantities. ``Flight.prints.dynamic_stability()`` summarizes them at +the key ascent instants, rail departure and burnout, together with the roll +rate at burnout: + +.. jupyter-execute:: + + test_flight.prints.dynamic_stability() + +``Flight.plots.dynamic_stability_data()`` plots natural frequency and damping +ratio as functions of time, with roll rate overlaid. + +.. jupyter-execute:: + + test_flight.plots.dynamic_stability_data() + +.. _stability_in_flight: + +Part 5: Stability in a real flight +================================== + +Parts 1 to 4 defined each stability quantity on a single nominal flight. This +part watches those quantities at work: the wind disturbance at rail exit, the +margin and damping correcting it, and how stable the rocket stays across the +spread of conditions a real launch day brings. + +.. admonition:: The out-of-rail instant + :class: note + + Rail departure is the most critical moment of a rocket's flight. The rail + buttons hold its orientation until the last one clears the rail. From then + on, aerodynamics takes over, right as the rocket is at its slowest and + least able to resist a disturbance. + +Stall is not modeled +-------------------- + +**RocketPy does not model stall.** On a real fin, lift grows with angle of +attack only up to a point. Past roughly 10 to 15 degrees the flow +**separates** from the surface, the lift collapses, and the drag climbs +sharply: the fin *stalls*. A stalled fin **stops correcting**, and the rocket +can tumble out of controlled flight. + +RocketPy's Barrowman fins never do this. A fin takes its lift *slope* at zero +angle of attack and applies that **same slope at every angle**, so its normal +force keeps growing without limit: + +.. jupyter-execute:: + :hide-code: + + # Left: the measured NACA 0012 lift curve (the file stores angle in radians). + # Right: the RocketPy fin built from that same airfoil. RocketPy keeps only + # the curve's slope at 0 degrees, so the fin's coefficient never stalls. + airfoil = np.loadtxt("../data/airfoils/NACA0012-radians.txt", delimiter=",") + aoa_deg, cl = np.degrees(airfoil[:, 0]), airfoil[:, 1] + + fin = build_rocket().fins[0] + clalpha = fin.clalpha(0.1) # normal-force slope at low Mach + aoa = np.linspace(0, 15, 200) + cn_fin = clalpha * np.radians(aoa) + peak = np.argmax(cl[aoa_deg <= 20]) # airfoil peak, just before stall + + fig, (axL, axR) = plt.subplots(1, 2, figsize=(10, 3.8)) + + shown = aoa_deg <= 15 + axL.plot(aoa_deg[shown], cl[shown], "-o", color="#c0392b", lw=2, ms=4) + axL.annotate("stall", xy=(aoa_deg[peak], cl[peak]), + xytext=(aoa_deg[peak] + 1.5, cl[peak] + 0.03), + fontsize=11, fontweight="bold", color="#c0392b", + arrowprops=dict(arrowstyle="->", color="#c0392b")) + axL.set_title("Real airfoil (NACA 0012 data)", fontweight="bold") + axL.set_ylabel(r"lift coefficient $C_L$") + + axR.plot(aoa, cn_fin, color="#2980b9", lw=2.4) + axR.text(0.05, 0.9, "linear: never stalls", transform=axR.transAxes, + fontsize=11, fontweight="bold", color="#2980b9") + axR.set_title("RocketPy fin (Barrowman)", fontweight="bold") + axR.set_ylabel(r"normal-force coefficient $C_N$") + + for ax in (axL, axR): + ax.set_xlabel("angle of attack (deg)") + ax.set_xlim(0, 15) + ax.set_ylim(0, 0.9) + ax.grid(alpha=0.25) + ax.spines[["top", "right"]].set_visible(False) + fig.tight_layout() + plt.show() + +The airfoil on the left carries the whole story of the flow: lift rises, +**peaks near 9 degrees, then falls off a cliff** as the flow separates. The +RocketPy fin on the right, built from that very airfoil, **keeps the same +initial slope forever**. + +So **a high computed rail-exit angle of attack marks a failure, not a +survivable condition.** The simulation shows the rocket swinging back into +line even past the angle where a real fin would have stalled. Keep the +out-of-rail velocity high relative to the wind and the rocket stays below the +stall range; the recovery the simulation shows past it **would not happen in +reality**. In the sweeps below, read a large computed angle of attack as a +warning sign, not a number the simulation can be trusted to reproduce. + +To actually simulate stall, or any other measured nonlinear aerodynamics, +provide the coefficients directly with a generic surface (see +:ref:`genericsurfaces`). + +The angle of attack at rail exit +-------------------------------- + +At rail departure the body points along the rail. But the air it meets is the +vector sum of the rocket's own velocity and the wind. That gives an angle of +attack of approximately + +.. math:: + + \alpha_\text{exit} \approx \arctan\!\left(\frac{V_\text{wind}}{V_\text{exit}}\right), + +where :math:`V_\text{wind}` is the crosswind and :math:`V_\text{exit}` is the +out-of-rail velocity. Only their *ratio* matters: a stronger wind and a +slower exit velocity push the angle up the same way. The flight below, the +nominal Calisto in an 8 m/s crosswind, checks the estimate against a real +simulation: + +.. jupyter-execute:: + + import numpy as np + + windy_flight = Flight( + rocket=build_rocket(), environment=windy_site(5), + rail_length=5.2, inclination=85, heading=0, terminate_on_apogee=True, + ) + + v_exit = windy_flight.out_of_rail_velocity + aoa_exit = windy_flight.angle_of_attack(windy_flight.out_of_rail_time) + estimate = np.degrees(np.arctan(5 / v_exit)) + + print(f"out-of-rail velocity : {v_exit:5.1f} m/s") + print(f"rail-exit AoA : {aoa_exit:5.2f} deg (simulated)") + print(f"arctan(wind / V_exit): {estimate:5.2f} deg (geometric estimate)") + +The simulated angle tracks the arctangent estimate closely. This is the +disturbance set the instant the rail lets go, and the rest of the flight has +to correct it. Keeping it well below the stall range of the fins is the +first stability requirement of any launch. + +The disturbance, corrected +-------------------------- + +The damped oscillation from :ref:`part_dynamic` can be represented in a flight +simulation. Tracking the angle of +attack after rail exit shows the rocket swinging back toward the relative +wind, and the swing dying away: + +.. jupyter-execute:: + + t0 = windy_flight.out_of_rail_time + t = np.linspace(t0, t0 + 4, 400) + aoa = [windy_flight.angle_of_attack(ti) for ti in t] + + fig, ax = plt.subplots(figsize=(8, 4)) + ax.plot(t, aoa) + ax.axvline(t0, color="0.6", ls="--", lw=1, label="rail exit") + ax.set_xlabel("Time (s)") + ax.set_ylabel("Angle of attack (deg)") + ax.set_title("Response to the rail-exit disturbance (8 m/s crosswind)") + ax.legend() + ax.grid(True) + plt.show() + +Every stability quantity from earlier parts shows up here. The rocket returns +toward zero at all because its **stability margin** +(:ref:`stability_margin_part`) is positive. The center of pressure sits +behind the center of mass, so the aerodynamic force restores rather than +diverges. The *rate* of the wobble is the **natural frequency**. The *speed* +it settles at is the **damping ratio** (:ref:`part_dynamic`). +``windy_flight.prints.dynamic_stability()`` reports both for this flight. A +positive margin only guarantees the curve trends back to zero. It says +nothing about how fast or how smoothly, which is exactly the distinction +Part 4 draws. Here the angle of attack swings through several cycles before it +settles, a sign that this rocket is only lightly damped. It recovers either +way, but for a cleaner flight, one that settles after an overshoot or two, it +could do with more damping. + +Stability across a launch day +----------------------------- + +**A single nominal flight is not what a launch actually delivers.** The wind +shifts from minute to minute. The finished mass differs from the design +value. The center of mass is never exactly where the drawing puts it. That +spread of conditions moves two quantities that matter at rail exit: the +stability margin and the angle of attack. + +RocketPy's Monte Carlo tooling measures exactly that. The ``Stochastic*`` +classes wrap the nominal environment, rocket, motor and flight, and attach a +spread to each uncertain input. :class:`rocketpy.MonteCarlo` then runs the +flight many times, each with a fresh draw, and saves the results. Here the +balance (dry mass and center of mass), the motor's total impulse and burn +time, and the crosswind are dispersed: + +.. code-block:: python + + from rocketpy import MonteCarlo, NoseCone, TrapezoidalFins, Tail + from rocketpy.stochastic import ( + StochasticEnvironment, + StochasticRocket, + StochasticSolidMotor, + StochasticFlight, + StochasticNoseCone, + StochasticTrapezoidalFins, + StochasticTail, + ) + + # Environment: 3 m/s mean crosswind, scaled by a normal factor so the wind + # spans roughly 3 +/- 2.5 m/s from flight to flight. + stochastic_env = StochasticEnvironment( + environment=windy_site(3), + wind_velocity_x_factor=(1.0, 0.42, "normal"), + ) + + # Motor: total impulse and burn time vary together, the way two motors from + # the same production lot differ. Roughly +/- 3%, a typical manufacturing + # tolerance; reshapes the whole thrust curve to match each draw. + stochastic_motor = StochasticSolidMotor( + solid_motor=Pro75M1670, + total_impulse=(6026, 180, "normal"), # newton-seconds + burn_out_time=(3.9, 0.12, "normal"), # seconds + ) + + # Rocket: same Calisto, but the dry mass and the balance point vary too. + # The surfaces are added back with no spread (fixed geometry). + stochastic_rocket = StochasticRocket( + rocket=rocket, + mass=(14.426, 0.4, "normal"), # dry mass, kg + center_of_mass_without_motor=(0.0, 0.03, "normal"), # balance, m + ) + stochastic_rocket.add_motor(stochastic_motor, position=(-1.255, 0)) + fixed_surface = { + NoseCone: (StochasticNoseCone, stochastic_rocket.add_nose), + TrapezoidalFins: (StochasticTrapezoidalFins, + stochastic_rocket.add_trapezoidal_fins), + Tail: (StochasticTail, stochastic_rocket.add_tail), + } + # Re-add each surface with no spread; a (value, 0) position means "fixed". + for surface, position in rocket.aerodynamic_surfaces: + stochastic_surface, add = fixed_surface[type(surface)] + add(stochastic_surface(surface), (position.z, 0)) + + # Flight: fixed rail and launch angles; stop each run at apogee to save time. + base_flight = Flight( + rocket=rocket, environment=windy_site(5), + rail_length=5.2, inclination=85, heading=0, terminate_on_apogee=True, + ) + stochastic_flight = StochasticFlight(flight=base_flight, terminate_on_apogee=True) + + # A data_collector callback receives each finished flight and returns a value, + # here the rail-exit angle of attack (not one of the standard exports). + analysis = MonteCarlo( + filename="../data/monte_carlo/stability_dispersion", + environment=stochastic_env, + rocket=stochastic_rocket, + flight=stochastic_flight, + data_collector={ + "rail_exit_aoa": lambda f: f.angle_of_attack(f.out_of_rail_time), + }, + ) + analysis.simulate(number_of_simulations=100, include_function_data=False) + + analysis.set_results() + margins = np.array(analysis.results["out_of_rail_stability_margin"]) + aoa_exits = np.array(analysis.results["rail_exit_aoa"]) + +.. jupyter-execute:: + :hide-code: + :hide-output: + + # The docs build does not run the Monte Carlo above; it loads the committed + # output below instead. Regenerate it by running the code above (which writes + # to the same path) after changing any distribution. + import json + + results_file = "../data/monte_carlo/stability_dispersion.outputs.txt" + with open(results_file, encoding="utf-8") as f: + records = [json.loads(line) for line in f] + margins = np.array([r["out_of_rail_stability_margin"] for r in records]) + aoa_exits = np.array([r["rail_exit_aoa"] for r in records]) + +The two quantities are now distributions over a simulated launch day: + +.. jupyter-execute:: + + fig, (axm, axa) = plt.subplots(1, 2, figsize=(10, 4)) + axm.hist(margins, bins=20, color="#4c72b0") + axm.axvspan(2.0, 3.0, color="green", alpha=0.12, label="2-3 cal target") + axm.set_xlabel("Out-of-rail stability margin (cal)") + axm.set_ylabel("Simulations") + axm.legend() + axa.hist(aoa_exits, bins=20, color="#c44e52") + axa.axvline(10, color="k", ls="--", label="stall onset (~10 deg)") + axa.set_xlabel("Rail-exit angle of attack (deg)") + axa.legend() + fig.suptitle(f"Launch-day dispersion ({margins.size} simulations)") + fig.tight_layout() + plt.show() + + print(f"out-of-rail margin : {np.percentile(margins, 5):.2f} to " + f"{np.percentile(margins, 95):.2f} cal (5th-95th percentile)") + print(f"rail-exit AoA : 95th pct {np.percentile(aoa_exits, 95):.1f} deg, " + f"worst {aoa_exits.max():.1f} deg") + print(f"flights above stall: {100 * np.mean(aoa_exits > 10):.0f}%") + +This is a concrete deliverable of a stability analysis. Not a single +margin, but a *distribution* read against the design thresholds. If a meaningful +fraction of flights cross either line, the rail, the mass or the margin needs +another look before flying. + +.. seealso:: + + A full dispersion study varies every input, not just five, and runs the + flights in parallel. ``analysis.simulate(..., parallel=True)`` does that, + and the ``Stochastic*`` classes cover parachutes and rail buttons too. See + :ref:`stochastic_usage` for the class walkthrough, and :ref:`MRS` for + weighting a finished sample toward measured launch-day conditions. + +Part 6: How much does the static margin matter? +=============================================== + +The dispersion above shows a Calisto-class rocket that is comfortably stable, +and yet a great deal of design effort in rocketry goes into chasing static +margin. The simulation lets us weigh that margin against the other things a +builder can change, and see **how much it really decides**, following +Thomas Fetter's flight-data study *How Far Does a Rocket Turn Into the +Wind?* (NARCON-2024). + +A rocket launched straight up into a crosswind turns as it climbs, and by the +time the motor burns out its flight path has tilted some degrees away from +vertical. This tilt is the *turn*, and because the launch was vertical it is +**entirely the rocket's response to the wind**, the weathercocking that +stability is meant to hold in check. Sweeping each design parameter on its own across a +realistic range, with the others left at their nominal values, shows how much +each one moves the turn: + +.. jupyter-execute:: + + def turn_at_burnout(flight): + """Flight-path tilt away from vertical at motor burnout, in degrees.""" + return 90 - flight.path_angle(flight.rocket.motor.burn_out_time) + + def vertical_flight(rocket, wind, rail=5.2): + return Flight( + rocket=rocket, environment=windy_site(wind), + rail_length=rail, inclination=90, heading=0, terminate_on_apogee=True, + ) + + def sweep(values, make_flight): + flights = [make_flight(v) for v in values] + return np.array([turn_at_burnout(f) for f in flights]), flights + + # Each lever swept alone across a realistic range; others nominal, 5 m/s wind. + winds = np.linspace(0, 14, 9) # crosswind, m/s + rails = np.linspace(1.2, 9.0, 8) # rail length sets the exit velocity + cgs = np.linspace(-0.25, 0.9, 10) # sets the static margin + masses = np.linspace(9, 30, 8) # dry mass, kg + + turn_wind, _ = sweep(winds, lambda w: vertical_flight(build_rocket(), w)) + turn_rail, rail_f = sweep(rails, lambda r: vertical_flight(build_rocket(), 5, rail=r)) + turn_cg, cg_f = sweep(cgs, lambda c: vertical_flight(build_rocket(c), 5)) + turn_mass, _ = sweep(masses, lambda m: vertical_flight(build_rocket(mass=m), 5)) + + exit_v = np.array([f.out_of_rail_velocity for f in rail_f]) + margins = np.array([f.rocket.static_margin(0) for f in cg_f]) + + panels = [ + (winds, turn_wind, "crosswind (m/s)", "wind speed", "#c0392b"), + (exit_v, turn_rail, "exit velocity (m/s)", "exit velocity (rail length)", "#e67e22"), + (margins, turn_cg, "static margin (cal)", "static margin", "#2980b9"), + (masses, turn_mass, "dry mass (kg)", "mass", "#27ae60"), + ] + ymax = max(y.max() for _, y, *_ in panels) + + fig, axs = plt.subplots(2, 2, figsize=(9.5, 7), sharey=True) + for ax, (x, y, xlabel, title, color) in zip(axs.flat, panels): + ax.fill_between(x, 0, y, color=color, alpha=0.12) + ax.plot(x, y, "-o", color=color, lw=2.4, ms=6, mfc=color, mec="white", mew=0.8) + ax.annotate(f"swing {y[-1] - y[0]:+.1f}°", # signed: low end -> high end + xy=(0.04, 0.92), xycoords="axes fraction", ha="left", va="top", + fontsize=11, fontweight="bold", color=color) + ax.set_title(title, fontweight="bold") + ax.set_xlabel(xlabel) + ax.grid(True, alpha=0.3) + ax.set_ylim(0, ymax * 1.12) + axs[0, 0].set_ylabel("turn at burnout (deg)") + axs[1, 0].set_ylabel("turn at burnout (deg)") + fig.suptitle("What moves the turn into the wind? (each lever alone; others nominal, " + "5 m/s wind)", fontsize=12, fontweight="bold") + fig.tight_layout() + plt.show() + +Each panel is labeled with its *swing*, meaning how many degrees the turn +changes as that parameter goes from the low end of its range to the high end. + +The wind dominates, mass comes next, and a higher exit velocity has a +moderate effect the other way, lowering the turn. + +**The static margin is the weakest of the four**: across a change in margin +the turn barely moves, and it levels off at high margins, holding steady well +past the over-stable range. For a Calisto-class rocket the margin is simply +not what decides how far it weathercocks. A rocket that turns hard into the +wind is easy to **misjudge as over- or super-stable**. + +What static margin it does instead is *correction*. +Keeping the center of pressure behind the center of mass is what lets a +disturbance correct itself at all (:ref:`stability_margin_part` and +:ref:`part_dynamic`), and the rail-exit angle of attack still has to stay +below the stall range. Beyond that, **a larger margin does little for a +flight like this one**. + +Part 7: Pitch and yaw planes +============================ + +A rocket with evenly spaced fins, like Calisto, is **axisymmetric**. Its +geometry does not change under rotation about the body axis, so its +stability is the same in every plane, and a single margin describes it +fully. For these rockets, ``Rocket.is_axisymmetric`` returns ``True``, and +the rest of this section does not apply. + +Some configurations are **not** axisymmetric: canards on a single axis, +off-center payloads, fins arranged asymmetrically. For these, stability +differs between the **pitch** plane and the **yaw** plane, and RocketPy +computes each one independently: + +- pitch: ``aerodynamic_center``, ``static_margin``, ``stability_margin``; +- yaw: ``aerodynamic_center_yaw``, ``static_margin_yaw``, ``stability_margin_yaw``. + +For an axisymmetric rocket, the two planes coincide. When they do not, +RocketPy issues a warning, because the unqualified ``static_margin`` then +describes the pitch plane only. In that case, the plotting and print +methods report both planes. + +Helper code +=========== + +Every worked example on this page is built on the same Calisto reference +rocket and a handful of small helper functions. Most of that code runs behind +the scenes so the examples above can stay focused on one idea at a time. It is +gathered here so it can be read and reused. The data-file paths are written +relative to the RocketPy ``docs`` directory; adjust them to your own setup. + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + + from rocketpy import Environment, SolidMotor, Rocket, Flight + + # The Calisto reference rocket from the First Simulation guide, reused for + # every worked example on this page. + env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + env.set_atmospheric_model(type="standard_atmosphere") + + Pro75M1670 = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + + rocket = Rocket( + radius=127 / 2000, + 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", + ) + rocket.add_motor(Pro75M1670, position=-1.255) + rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + cant_angle=0.0, + position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + + test_flight = Flight( + rocket=rocket, environment=env, rail_length=5.2, inclination=85, heading=0 + ) + +The design studies build fresh rockets and windy environments through two +small factories: + +.. code-block:: python + + def build_rocket(center_of_mass_without_motor=0.0, lateral_inertia=6.321, + mass=14.426, motor=Pro75M1670): + """Calisto with four adjustable parameters. Shifting the dry center of + mass toward the nose (positive) raises the static margin; toward the tail + (negative) lowers it. The lateral moment of inertia is adjustable + separately, which changes the dynamic response without changing the + static margin. The dry mass (in kilograms) and the motor can also be + swapped, for the mass and thrust studies later in this document. + Everything else, including the aerodynamics, is fixed. + """ + rocket = Rocket( + radius=127 / 2000, + mass=mass, + inertia=(lateral_inertia, lateral_inertia, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=center_of_mass_without_motor, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(motor, position=-1.255) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.060, span=0.110, + cant_angle=0.0, position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + return rocket + + def windy_site(wind_speed): + """A standard atmosphere with a constant eastward crosswind, in m/s.""" + site = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + site.set_atmospheric_model( + type="custom_atmosphere", wind_u=wind_speed, wind_v=0 + ) + return site + +The three helpers that measure the turn and sweep one parameter at a time +(``turn_at_burnout``, ``vertical_flight`` and ``sweep``) are shown inline where +they are used, in Part 6 above. + diff --git a/docs/user/first_simulation.rst b/docs/user/first_simulation.rst index 18e4b9882..85b418705 100644 --- a/docs/user/first_simulation.rst +++ b/docs/user/first_simulation.rst @@ -310,6 +310,12 @@ We can then see if the rocket is stable by plotting the static margin: If it is unreasonably **high**, your rocket is **super stable** and the simulation will most likely **fail**. +.. seealso:: + + For a full treatment of static margin, stability margin and dynamic + stability, including what these numbers mean, what values to aim for, and + how they play out over a real flight, see :ref:`aero_cp_stability`. + To guarantee that the rocket is stable, the positions of all added components must be correct. The ``Rocket`` class can help you with the ``draw`` method: @@ -580,15 +586,21 @@ following method: test_flight.plots.fluid_mechanics_data() -Stability Margin and Frequency Response +Stability Margin and Dynamic Stability ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The Stability margin can be checked along with the frequency response of the -rocket: +The stability margin over the flight: + +.. jupyter-execute:: + + test_flight.plots.stability_margin_data() + +The dynamic-stability quantities (natural frequency, damping ratio and the +attitude frequency response): .. jupyter-execute:: - test_flight.plots.stability_and_control_data() + test_flight.plots.dynamic_stability_data() Visualizing the Trajectory in Google Earth diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 31e7ab588..a356c7dfe 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -474,7 +474,8 @@ Energy Analysis flight.plots.energy_data() # Stability analysis - flight.plots.stability_and_control_data() + flight.plots.stability_margin_data() + flight.plots.dynamic_stability_data() Comprehensive Analysis ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/user/index.rst b/docs/user/index.rst index c44527670..218fb9e4f 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -47,4 +47,5 @@ RocketPy's User Guide :caption: Further Analysis Function - Utilities \ No newline at end of file + Utilities + Center of Pressure and Stability \ No newline at end of file diff --git a/docs/user/rocket/generic_surface.rst b/docs/user/rocket/generic_surface.rst index 70cbd4d36..ffe210be8 100644 --- a/docs/user/rocket/generic_surface.rst +++ b/docs/user/rocket/generic_surface.rst @@ -3,7 +3,7 @@ Generic Surfaces and Custom Aerodynamic Coefficients ==================================================== -Generic aerodynamic surfaces can be used to model aerodynamic forces based on +Generic aerodynamic surfaces can be used to model aerodynamic forces based on force and moment coefficients. The :class:`rocketpy.GenericSurface` receives the coefficients as functions of the angle of attack, side slip angle, Mach number, Reynolds number, pitch rate, yaw rate, and roll rate. @@ -16,119 +16,88 @@ slip angle, Mach number, Reynolds number, pitch rate, yaw rate, and roll rate. These classes allows the user to be less dependent on the built-in aerodynamic surfaces and to define their own aerodynamic coefficients. -Both classes base their coefficient on the definition of the aerodynamic frame -of reference. +Wind Frame and Body Frame +------------------------- -Aerodynamic Frame ------------------ - -The aerodynamic frame of reference of the rocket is defined as follows: - -- The origin is at the rocket's center of dry mass (``center_of_dry_mass_position``). -- The ``z`` axis is defined along the rocket's centerline, pointing from the center of dry mass towards the nose. -- The ``x`` and ``y`` axes are perpendicular. -- The partial angle of attack (``alpha``) is defined as the angle, in the y-z - plane, from the velocity vector to the z axis. -- The partial side slip angle (``beta``) is defined as the angle, in the x-z - plane, from the velocity vector to the z axis. +A surface's aerodynamic force is the same physical vector written in two +coordinate frames: the **body frame**, fixed to the rocket, and the **wind +frame**, aligned with the airflow. You can provide the coefficients in either +frame; the two are related by the angle of attack and sideslip defined below. -The following figure shows the aerodynamic frame of reference: +The following figure shows the body frame (subscript :math:`B`) and the wind +frame (subscript :math:`W`): .. figure:: ../../static/rocket/aeroframe.png :align: center - :alt: Aerodynamic frame of reference + :alt: Wind frame of reference In the figure we define: - :math:`\mathbf{\vec{V}}` as rocket velocity vector. - :math:`x_B`, :math:`y_B`, and :math:`z_B` as the body axes. -- :math:`x_A`, :math:`y_A`, and :math:`z_A` as the aerodynamic axes. +- :math:`x_W`, :math:`y_W`, and :math:`z_W` as the wind-frame axes. - :math:`\alpha` as the partial angle of attack. - :math:`\beta` as the side slip angle. - :math:`L` as the lift force. - :math:`D` as the drag force. -- :math:`Q` as the side force. +- :math:`Q` as the wind frame side force. +- :math:`N` as the normal force. +- :math:`A` as the axial force. +- :math:`Y` as the body frame side force. -Here we define the aerodynamic forces in the aerodynamic frame of reference as: +Body frame +~~~~~~~~~~ -.. math:: - \vec{\mathbf{F}}_A=\begin{bmatrix}X_A\\Y_A\\Z_A\end{bmatrix}_A=\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_A - -The aerodynamic forces in the body axes coordinate system are defined as -:math:`\vec{\mathbf{F}}_B`. +The body frame is fixed to the rocket: -.. math:: - \vec{\mathbf{F}}_B=\begin{bmatrix}X_A\\Y_A\\Z_A\end{bmatrix}_B=\mathbf{M}_{BA}\cdot\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_A +- The origin is at the rocket's center of dry mass (``center_of_dry_mass_position``). +- The :math:`z_B` axis lies along the rocket's centerline, pointing from the center of dry mass towards the nose. +- The :math:`x_B` and :math:`y_B` axes are perpendicular to it. -Where the transformation matrix :math:`\mathbf{M}_{BA}`, which transforms the -aerodynamic forces from the aerodynamic frame of reference to the body axes -coordinate system, is defined as: +In this frame the aerodynamic force is made up of the normal force :math:`N`, +the side force :math:`Y` and the axial force :math:`A`. As in the wind frame, the +side force is the :math:`x_B` component, while the normal and axial forces enter +the :math:`y_B` and :math:`z_B` components with a negative sign: .. math:: - \mathbf{M}_{BA} = \begin{bmatrix} - 1 & 0 & 0 \\ - 0 & \cos(\alpha) & -\sin(\alpha) \\ - 0 & \sin(\alpha) & \cos(\alpha) - \end{bmatrix} - \begin{bmatrix} - \cos(\beta) & 0 & -\sin(\beta) \\ - 0 & 1 & 0 \\ - \sin(\beta) & 0 & \cos(\beta) - \end{bmatrix} - + \vec{\mathbf{F}}_B=\begin{bmatrix}X_B\\Y_B\\Z_B\end{bmatrix}_B=\begin{bmatrix}Y\\-N\\-A\end{bmatrix}_B -The forces coefficients can finally be defined as: +Wind frame +~~~~~~~~~~ -- :math:`C_L` as the lift coefficient. -- :math:`C_Q` as the side force coefficient (or cross stream force coefficient). -- :math:`C_D` as the drag coefficient. - -And the forces from the coefficients are defined as: +The wind frame is aligned with the airflow: its :math:`z_W` axis runs along the +velocity vector. In this frame the aerodynamic force is the lift :math:`L`, the +drag :math:`D` and the (wind-frame) side force :math:`Q`: .. math:: - \begin{bmatrix}X_A\\Y_A\\Z_A\end{bmatrix}_B =\mathbf{M}_{BA}\cdot\overline{q}\cdot A_{ref}\cdot\begin{bmatrix}C_Q\\-C_L\\-C_D\end{bmatrix}_A - -Where: + \vec{\mathbf{F}}_W=\begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix}_W=\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_W -- :math:`\bar{q}` is the dynamic pressure. -- :math:`A_{ref}` is the reference area used to calculate the coefficients. - Commonly the rocket's cross-sectional area is used as the reference area. - -The moment coefficients can be defined as: - -- :math:`C_l` as the rolling moment coefficient. -- :math:`C_m` as the pitching moment coefficient. -- :math:`C_n` as the yawing moment coefficient. +Relating the two frames +~~~~~~~~~~~~~~~~~~~~~~~~ -And the moments from the coefficients are defined as: +The two are the same force, related by the angle-of-attack/sideslip rotation +:math:`\mathbf{M}_{BW}`, which transforms the wind frame into the body frame: .. math:: - \vec{\mathbf{M}}_B=\begin{bmatrix}M_{x_A}\\M_{y_A}\\M_{z_A}\end{bmatrix}_B =\overline{q}\cdot A_{ref}\cdot L_{ref}\cdot\begin{bmatrix}C_m\\C_n\\C_l\end{bmatrix} - -Where: - -- :math:`L_{ref}` is the reference length used to calculate the coefficients. - 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: + \vec{\mathbf{F}}_B=\mathbf{M}_{BW}\cdot\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_W -- :math:`C_L` (lift), :math:`C_Q` (side force) and :math:`C_D` (drag). +where -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). +.. math:: + \mathbf{M}_{BW} = \begin{bmatrix} + 1 & 0 & 0 \\ + 0 & \cos(\alpha) & \sin(\alpha) \\ + 0 & -\sin(\alpha) & \cos(\alpha) + \end{bmatrix} + \begin{bmatrix} + \cos(\beta) & 0 & \sin(\beta) \\ + 0 & 1 & 0 \\ + -\sin(\beta) & 0 & \cos(\beta) + \end{bmatrix} -The two sets are the same force in different frames, related by the -angle-of-attack/sideslip rotation :math:`\mathbf{M}_{BA}`: +The force coefficients follow the same rotation. In the wind frame they are the +lift :math:`C_L`, side :math:`C_Q` and drag :math:`C_D`; in the body frame the +normal :math:`C_N`, side :math:`C_Y` and axial :math:`C_A`: .. math:: \begin{aligned} @@ -140,56 +109,47 @@ angle-of-attack/sideslip rotation :math:`\mathbf{M}_{BA}`: 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. +The force itself is recovered from the coefficients with the dynamic pressure +:math:`\bar q` and the reference area :math:`A_{ref}`. From **body-frame** +coefficients the force is obtained directly, with no rotation: -**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. +.. math:: + \vec{\mathbf{F}}_B =\begin{bmatrix}Y\\-N\\-A\end{bmatrix}_B= \overline{q}\cdot A_{ref}\cdot\begin{bmatrix}C_Y\\-C_N\\-C_A\end{bmatrix}_B -Moment reference point -~~~~~~~~~~~~~~~~~~~~~~~~ +while **wind-frame** coefficients are rotated into the body frame first: -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. +.. math:: + \vec{\mathbf{F}}_B =\mathbf{M}_{BW}\cdot\overline{q}\cdot A_{ref}\cdot\begin{bmatrix}C_Q\\-C_L\\-C_D\end{bmatrix}_W -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: +where :math:`\bar{q}` is the dynamic pressure and :math:`A_{ref}` the reference +area (commonly the rocket's cross-sectional area). -.. math:: - C_{m,\,\text{cp}} = C_{m,\,\text{ref}} + \frac{d}{L_{ref}}\, C_N +Moments +~~~~~~~ -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. +The moment coefficients are the same in both frames: the rolling moment +:math:`C_l`, the pitching moment :math:`C_m` and the yawing moment :math:`C_n`. +The moments about the body axes follow with the reference area and the reference +length :math:`L_{ref}`: +.. math:: + \vec{\mathbf{M}}_B=\begin{bmatrix}M_{x}\\M_{y}\\M_{z}\end{bmatrix}_B =\overline{q}\cdot A_{ref}\cdot L_{ref}\cdot\begin{bmatrix}C_m\\C_n\\C_l\end{bmatrix} + +where :math:`L_{ref}` is the reference length (commonly the rocket's diameter). -Aerodynamic angles -~~~~~~~~~~~~~~~~~~ +Angles of attack and sideslip +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The aerodynamic angles are defined in two different ways in RocketPy: +RocketPy uses the flow angles two ways: -- As the angle of attack (:math:`\alpha`) and the side slip \ - angle (:math:`\beta`), which are defined in the image above. These are used \ - in the calculation of the generic surface forces and moments. -- As the total angle of attack (:math:`\alpha_{\text{tot}}`), defined as the \ - angle between the total velocity vector and the rocket's centerline. This is \ - used in the calculation of the standard aerodynamic surface forces and moments. +- the partial **angle of attack** :math:`\alpha` and **sideslip angle** + :math:`\beta` (shown in the figure above), used by the generic-surface forces + and moments; +- the **total angle of attack** :math:`\alpha_{\text{tot}}`, the angle between + the total velocity vector and the rocket's centerline, used by the standard + (Barrowman) surfaces. -The partial angles are calculated as: +The partial angles are .. math:: \begin{aligned} @@ -197,69 +157,119 @@ The partial angles are calculated as: \beta &= \arctan\left(\frac{V_x}{V_z}\right) \end{aligned} -The total angle of attack is calculated as: +and the total angle of attack is .. math:: \alpha_{\text{tot}} = \arccos\left(\frac{\mathbf{\vec{V}}\cdot\mathbf{z_B}}{||\mathbf{\vec{V}}||\cdot||\mathbf{z_B}||}\right) .. note:: When the simulation is done, the total angle of attack is accessed through - the :attr:`rocketpy.Flight.angle_of_attack` attribute. - The partial angles of attack and side slip are accessed through the - :attr:`rocketpy.Flight.partial_angle_of_attack` and + the :attr:`rocketpy.Flight.angle_of_attack` attribute. The partial angles of + attack and sideslip are accessed through the + :attr:`rocketpy.Flight.partial_angle_of_attack` and :attr:`rocketpy.Flight.angle_of_sideslip` attributes, respectively. + .. _genericsurface: Generic Surface Class --------------------- -The :class:`rocketpy.GenericSurface` class is used to define an aerodynamic -surface based on force and moment coefficients. A generic surface is defined -as follows: +The :class:`rocketpy.GenericSurface` class defines an aerodynamic surface +directly from its force and moment coefficients. A surface is created by giving +it a reference area and length, the coefficients, and a few optional settings: .. seealso:: - For more information on class initialization, see - :class:`rocketpy.GenericSurface.__init__` + For more information on class initialization, see + :class:`rocketpy.GenericSurface.__init__` .. code-block:: python + import numpy as np from rocketpy import GenericSurface - + radius = 0.0635 - + generic_surface = GenericSurface( reference_area=np.pi * radius**2, reference_length=2 * radius, coefficients={ - "cL": "cL.csv", - "cQ": "cQ.csv", - "cD": "cD.csv", + "cN": "cN.csv", + "cY": "cY.csv", + "cA": "cA.csv", "cm": "cm.csv", "cn": "cn.csv", "cl": "cl.csv", }, + center_of_pressure=(0, 0, 0), name="Generic Surface", + reynolds_length=2 * radius, + interpolation="linear", + extrapolation="constant", + force_convention="body", + active_during="always", ) -The ``coefficients`` argument is a dictionary containing the coefficients of the -generic surface. The keys of the dictionary are the coefficient names, and the -values are the coefficients. The possible coefficient names are: +Constructor parameters +~~~~~~~~~~~~~~~~~~~~~~~~ -- ``cL``: Lift coefficient. -- ``cQ``: Side force coefficient. -- ``cD``: Drag coefficient. +:class:`rocketpy.GenericSurface` takes the following parameters: + +- ``reference_area`` (int or float): reference area used to non-dimensionalize + the coefficients, in :math:`m^2`. Commonly the rocket's cross-sectional area. +- ``reference_length`` (int or float): reference length, in meters, used to + non-dimensionalize the moment coefficients and the reduced rotation rates. + Commonly the rocket's diameter. +- ``coefficients`` (dict): the force and moment coefficients, by name (detailed + in `Coefficients`_ below). +- ``center_of_pressure`` (tuple, optional): the point where the surface's forces + and moments are applied, in the surface's local frame. Default ``(0, 0, 0)``. + See `Moment reference point`_. +- ``name`` (str, optional): a name for the surface. Default + ``"Generic Surface"``. +- ``reynolds_length`` (int or float, optional): length scale, in meters, of the + Reynolds number fed to the coefficients. Default ``None`` (uses + ``reference_length``). +- ``interpolation`` (str or dict, optional): how tabulated coefficients are + interpolated between their data points. Default ``None``. See + :ref:`generic_surface_interpolation`. +- ``extrapolation`` (str or dict, optional): how tabulated coefficients behave + outside their tabulated range. Default ``None``. See + :ref:`generic_surface_interpolation`. +- ``force_convention`` (str, optional): the frame the force coefficients are + given in, ``"body"`` or ``"wind"``. Default ``None`` (inferred from the + coefficient names). +- ``active_during`` (str or callable, optional): when the surface produces + aerodynamic force during the flight. Default ``"always"``. See + :ref:`active_during`. + +Coefficients +~~~~~~~~~~~~~ + +The ``coefficients`` argument is a dictionary mapping each coefficient's name to +its value. The body-frame coefficient names are: + +- ``cN``: Normal force coefficient (perpendicular to the body axis). +- ``cY``: Side force coefficient. +- ``cA``: Axial force coefficient (along the body axis). - ``cm``: Pitching moment coefficient. - ``cn``: Yawing moment coefficient. - ``cl``: Rolling moment coefficient. -Only one of the coefficients is required to be provided, but any combination of -the coefficients can be used. The coefficient values can be provided as a -single value, a callable function of seven arguments, or a path to a ``.csv`` -file containing the values. +Alternatively, you can supply the force coefficients in the **wind frame** as +``cL`` (lift), ``cQ`` (side) and ``cD`` (drag) in place of ``cN``/``cY``/``cA`` +(the moment coefficients ``cm``/``cn``/``cl`` are shared by both frames). By +default the frame is inferred from the names you pass; set ``force_convention`` +(``"body"`` or ``"wind"``) to state it explicitly. Whichever frame you choose, +all nine coefficients remain available as attributes (``surface.cN``, +``surface.cL``, ...), converted on demand from the ones you provided using the +rotation described in `Relating the two frames`_ above. + +Only one coefficient is required, and any combination can be provided; the ones +you omit are treated as zero. -The coefficients are all functions of: +Each coefficient is a function of the same seven independent variables: - Angle of attack (:math:`\alpha`) in radians. - Side slip angle (:math:`\beta`) in radians. @@ -279,35 +289,23 @@ The coefficients are all functions of: 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. + freestream speed. 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} - C_L &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_Q &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_D &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_m &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_n &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_l &= f(\alpha, \beta, Ma, Re, q, r, p) - \end{aligned} +Once evaluated, the coefficients are turned into body-frame forces and moments +exactly as described in `Wind Frame and Body Frame`_ above (wind-frame inputs +are rotated into the body frame first). -From the coefficients, the forces and moments are calculated with +Each coefficient value can be given three ways: a single number for a constant, +a callable function of the seven variables, or a path to a ``.csv`` file of +tabulated data. -.. math:: - \begin{aligned} - L &= \overline{q}\cdot A_{ref}\cdot C_L \\ - Q &= \overline{q}\cdot A_{ref}\cdot C_Q \\ - D &= \overline{q}\cdot A_{ref}\cdot C_D \\ - M_{m} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_m \\ - M_{n} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_n \\ - M_{l} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_l - \end{aligned} +Defining a coefficient as a callable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -These coefficients can be defined as a callable such as: +A coefficient can be any callable that takes the seven independent variables and +returns the value: .. code-block:: python @@ -315,11 +313,14 @@ These coefficients can be defined as a callable such as: ... return value -In which any algorithm can be implemented to calculate the coefficient values. +Any algorithm can be implemented inside to compute the coefficient. + +Defining a coefficient from a CSV file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Otherwise, the coefficients can be defined as a ``.csv`` file. The file must -contain a header with at least one of the following columns representing the -independent variables: +A coefficient can also be tabulated in a ``.csv`` file. The file must have a +header naming its columns. The independent-variable columns are optional, but +those present must use these exact names: - ``alpha``: Angle of attack. - ``beta``: Side slip angle. @@ -329,28 +330,16 @@ independent variables: - ``yaw_rate``: Yaw rate. - ``roll_rate``: Roll rate. -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. +The **last** column holds the coefficient value; it **must** have a header, but +the header name can be anything. .. important:: - Not all columns need to be present in the file, but the columns that are - present must be correctly named as described above. Independent variable - columns can be in any order. + Not all independent-variable columns need to be present, but the columns that + are present must be named exactly as above. They can be in any order. -An example of a ``.csv`` file is shown below: +An example ``.csv`` file, tabulated against angle of attack and Mach: -.. code-block:: +.. code-block:: "alpha", "mach", "coefficient" -0.017, 0, -0.11 @@ -366,12 +355,22 @@ An example of a ``.csv`` file is shown below: 0.017, 2, 0.084 0.017, 3, 0.061 -After the definition of the ``GenericSurface`` object, it must be added to the -rocket's configuration: +.. note:: + The ``reynolds`` axis is by default based on the reference length (the + rocket diameter). Published rocket data often bases the Reynolds number on + the **body length** instead, which for a slender rocket is much larger. If + your table uses a different length, pass it as ``reynolds_length`` when + creating the surface so the Reynolds number the simulation feeds your table + matches the one it was built against. + +Adding the surface to the rocket +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once defined, the surface is added to the rocket like any other: .. seealso:: For more information on how to add a generic surface to the rocket, see - :class:`rocketpy.Rocket.add_generic_surface` + :class:`rocketpy.Rocket.add_surfaces` .. code-block:: python :emphasize-lines: 5 @@ -382,20 +381,41 @@ rocket's configuration: ) rocket.add_surfaces(generic_surface, position=(0,0,0)) -The position of the generic surface is defined in the User Defined coordinate -System, see :ref:`rocket_axes` for more information. +The position is given in the User Defined Coordinate System; see +:ref:`rocket_axes` for more information. + +.. attention:: + If the generic surface is positioned **not** at the center of dry mass, the + forces generated by the force coefficients (``cN``, ``cY``, ``cA``) generate + a moment about the center of dry mass. This moment is computed and added to + the moment generated by the moment coefficients (``cm``, ``cn``, ``cl``). .. tip:: - If defining the coefficients of the entire rocket is desired, only a single - generic surface can be added to the rocket, positioned at the center of dry - mass. This will be equivalent to defining the coefficients of the entire - rocket. + To describe the whole vehicle with a single set of coefficients (rather than + one surface per component), use + :meth:`rocketpy.Rocket.add_full_body_aerodynamics`. See + :ref:`fullbodyaerodynamics`. -.. attention:: - If there generic surface is positioned **not** at the center of dry mass, the - forces generated by the force coefficients (cL, cQ, cD) will generate a - moment around the center of dry mass. This moment will be calculated and - added to the moment generated by the moment coefficients (cm, cn, cl). +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. 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. .. _lineargenericsurface: @@ -403,102 +423,122 @@ System, see :ref:`rocket_axes` for more information. Linear Generic Surface Class ---------------------------- -The :class:`rocketpy.LinearGenericSurface` class is used to define a aerodynamic -surface based on the forces and moments coefficient derivatives. A linear generic -surface will receive the derivatives of each coefficient with respect to the -independent variables. The derivatives are defined as: +The :class:`rocketpy.LinearGenericSurface` class defines an aerodynamic surface +from the **derivatives** of its force and moment coefficients, instead of the +coefficients themselves. This is convenient when you have stability-derivative +data rather than full tables: the surface builds each coefficient by summing its +derivatives times the independent variables. -- :math:`C_{\alpha}=\frac{dC}{d\alpha}`: Coefficient derivative with respect to angle of attack. -- :math:`C_{\beta}=\frac{dC}{d\beta}`: Coefficient derivative with respect to side slip angle. -- :math:`C_{Ma}=\frac{dC}{dMa}`: Coefficient derivative with respect to Mach number. -- :math:`C_{Re}=\frac{dC}{dRe}`: Coefficient derivative with respect to Reynolds number. -- :math:`C_{q}=\frac{dC}{dq}`: Coefficient derivative with respect to pitch rate. -- :math:`C_{r}=\frac{dC}{dr}`: Coefficient derivative with respect to yaw rate. -- :math:`C_{p}=\frac{dC}{dp}`: Coefficient derivative with respect to roll rate. +For every one of the six coefficients (``cN``, ``cY``, ``cA``, ``cm``, ``cn``, +``cl``), you provide a constant term and one derivative per independent variable: -A non derivative coefficient :math:`C_{0}` is also included. +- :math:`C_{0}`: the coefficient value at the reference condition. +- :math:`C_{\alpha}=\frac{dC}{d\alpha}`: derivative with respect to angle of attack. +- :math:`C_{\beta}=\frac{dC}{d\beta}`: derivative with respect to side slip angle. +- :math:`C_{Ma}=\frac{dC}{dMa}`: derivative with respect to Mach number. +- :math:`C_{Re}=\frac{dC}{dRe}`: derivative with respect to Reynolds number. +- :math:`C_{q}=\frac{dC}{dq}`: derivative with respect to pitch rate. +- :math:`C_{r}=\frac{dC}{dr}`: derivative with respect to yaw rate. +- :math:`C_{p}=\frac{dC}{dp}`: derivative with respect to roll rate. -Each coefficient derivative is defined as a function of all the seven -independent variables. +Just like the plain generic surface, each of these terms is itself a function of +all seven independent variables, and may be a constant, a callable, or a +tabulated ``.csv`` file. -The coefficients are then grouped into **forcing** coefficients: +How the coefficients are assembled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The derivatives are first combined into **forcing** coefficients, which depend on +the steady flow state (angles, Mach, Reynolds): .. math:: \begin{aligned} - C_{Lf} &= C_{L0} + C_{L\alpha}\cdot\alpha + C_{L\beta}\cdot\beta + C_{LMa}\cdot Ma + C_{LRe}\cdot Re \\ - C_{Qf} &= C_{Q0} + C_{Q\alpha}\cdot\alpha + C_{Q\beta}\cdot\beta + C_{QMa}\cdot Ma + C_{QRe}\cdot Re \\ - C_{Df} &= C_{D0} + C_{D\alpha}\cdot\alpha + C_{D\beta}\cdot\beta + C_{DMa}\cdot Ma + C_{DRe}\cdot Re \\ + C_{Nf} &= C_{N0} + C_{N\alpha}\cdot\alpha + C_{N\beta}\cdot\beta + C_{NMa}\cdot Ma + C_{NRe}\cdot Re \\ + C_{Yf} &= C_{Y0} + C_{Y\alpha}\cdot\alpha + C_{Y\beta}\cdot\beta + C_{YMa}\cdot Ma + C_{YRe}\cdot Re \\ + C_{Af} &= C_{A0} + C_{A\alpha}\cdot\alpha + C_{A\beta}\cdot\beta + C_{AMa}\cdot Ma + C_{ARe}\cdot Re \\ C_{mf} &= C_{m0} + C_{m\alpha}\cdot\alpha + C_{m\beta}\cdot\beta + C_{mMa}\cdot Ma + C_{mRe}\cdot Re \\ C_{nf} &= C_{n0} + C_{n\alpha}\cdot\alpha + C_{n\beta}\cdot\beta + C_{nMa}\cdot Ma + C_{nRe}\cdot Re \\ - C_{lf} &= C_{l0} + C_{l\alpha}\cdot\alpha + C_{l\beta}\cdot\beta + C_{lMa}\cdot Ma + C_{lRe}\cdot Re + C_{lf} &= C_{l0} + C_{l\alpha}\cdot\alpha + C_{l\beta}\cdot\beta + C_{lMa}\cdot Ma + C_{lRe}\cdot Re \end{aligned} -And **damping** coefficients: +and **damping** coefficients, which depend on the rotation rates: .. math:: \begin{aligned} - C_{Ld} &= C_{L_{q}}\cdot q + C_{L_{r}}\cdot r + C_{L_{p}}\cdot p \\ - C_{Qd} &= C_{Q_{q}}\cdot q + C_{Q_{r}}\cdot r + C_{Q_{p}}\cdot p \\ - C_{Dd} &= C_{D_{q}}\cdot q + C_{D_{r}}\cdot r + C_{D_{p}}\cdot p \\ + C_{Nd} &= C_{N_{q}}\cdot q + C_{N_{r}}\cdot r + C_{N_{p}}\cdot p \\ + C_{Yd} &= C_{Y_{q}}\cdot q + C_{Y_{r}}\cdot r + C_{Y_{p}}\cdot p \\ + C_{Ad} &= C_{A_{q}}\cdot q + C_{A_{r}}\cdot r + C_{A_{p}}\cdot p \\ C_{md} &= C_{m_{q}}\cdot q + C_{m_{r}}\cdot r + C_{m_{p}}\cdot p \\ C_{nd} &= C_{n_{q}}\cdot q + C_{n_{r}}\cdot r + C_{n_{p}}\cdot p \\ - C_{ld} &= C_{l_{q}}\cdot q + C_{l_{r}}\cdot r + C_{l_{p}}\cdot p + C_{ld} &= C_{l_{q}}\cdot q + C_{l_{r}}\cdot r + C_{l_{p}}\cdot p \end{aligned} -The forces and moments are then calculated as: +The body-frame forces and moments then follow, the damping terms scaled by the +reduced-rate factor :math:`\frac{L_{ref}}{2V}`: .. math:: \begin{aligned} - L &= \overline{q}\cdot A_{ref}\cdot C_{Lf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Ld} \\ - Q &= \overline{q}\cdot A_{ref}\cdot C_{Qf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Qd} \\ - D &= \overline{q}\cdot A_{ref}\cdot C_{Df} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Dd} \\ + N &= \overline{q}\cdot A_{ref}\cdot C_{Nf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Nd} \\ + Y &= \overline{q}\cdot A_{ref}\cdot C_{Yf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Yd} \\ + A &= \overline{q}\cdot A_{ref}\cdot C_{Af} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Ad} \\ M_{m} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_{mf} + \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot \frac{L_{ref}}{2V} C_{md} \\ M_{n} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_{nf} + \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot \frac{L_{ref}}{2V} C_{nd} \\ M_{l} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_{lf} + \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot \frac{L_{ref}}{2V} C_{ld} \end{aligned} -The linear generic surface is defined very similarly to the generic surface. -The coefficients are defined in the same way, but with the addition of the -derivative values. +Defining a linear generic surface +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -An example of a linear generic surface defined with **all** the coefficients is -shown below: +A linear generic surface takes the **same parameters** as +:class:`rocketpy.GenericSurface`, only the +``coefficients`` dictionary is different. Each key follows the pattern +``_``. For example ``cN_alpha`` is +:math:`C_{N\alpha}`, ``cm_q`` is :math:`C_{m_q}`, and ``cN_0`` is the constant +term :math:`C_{N0}`. Any term you omit is zero. + +.. note:: + The derivative names carry the force frame: ``cN_alpha``, ``cY_beta``, ... + in the body frame versus ``cL_alpha``, ``cQ_beta``, ... in the wind frame. + ``force_convention`` selects the frame just as it does for + :class:`rocketpy.GenericSurface`. .. seealso:: - For more information on class initialization, see + For more information on class initialization, see :class:`rocketpy.LinearGenericSurface.__init__` +An example defining **all** the coefficient derivatives: + .. code-block:: python - + from rocketpy import LinearGenericSurface linear_generic_surface = LinearGenericSurface( reference_area=np.pi * 0.0635**2, reference_length=2 * 0.0635, coefficients={ - "cL_0": "cL_0.csv", - "cL_alpha": "cL_alpha.csv", - "cL_beta": "cL_beta.csv", - "cL_Ma": "cL_Ma.csv", - "cL_Re": "cL_Re.csv", - "cL_q": "cL_q.csv", - "cL_r": "cL_r.csv", - "cL_p": "cL_p.csv", - "cQ_0": "cQ_0.csv", - "cQ_alpha": "cQ_alpha.csv", - "cQ_beta": "cQ_beta.csv", - "cQ_Ma": "cQ_Ma.csv", - "cQ_Re": "cQ_Re.csv", - "cQ_q": "cQ_q.csv", - "cQ_r": "cQ_r.csv", - "cQ_p": "cQ_p.csv", - "cD_0": "cD_0.csv", - "cD_alpha": "cD_alpha.csv", - "cD_beta": "cD_beta.csv", - "cD_Ma": "cD_Ma.csv", - "cD_Re": "cD_Re.csv", - "cD_q": "cD_q.csv", - "cD_r": "cD_r.csv", - "cD_p": "cD_p.csv", + "cN_0": "cN_0.csv", + "cN_alpha": "cN_alpha.csv", + "cN_beta": "cN_beta.csv", + "cN_Ma": "cN_Ma.csv", + "cN_Re": "cN_Re.csv", + "cN_q": "cN_q.csv", + "cN_r": "cN_r.csv", + "cN_p": "cN_p.csv", + "cY_0": "cY_0.csv", + "cY_alpha": "cY_alpha.csv", + "cY_beta": "cY_beta.csv", + "cY_Ma": "cY_Ma.csv", + "cY_Re": "cY_Re.csv", + "cY_q": "cY_q.csv", + "cY_r": "cY_r.csv", + "cY_p": "cY_p.csv", + "cA_0": "cA_0.csv", + "cA_alpha": "cA_alpha.csv", + "cA_beta": "cA_beta.csv", + "cA_Ma": "cA_Ma.csv", + "cA_Re": "cA_Re.csv", + "cA_q": "cA_q.csv", + "cA_r": "cA_r.csv", + "cA_p": "cA_p.csv", "cm_0": "cm_0.csv", "cm_alpha": "cm_alpha.csv", "cm_beta": "cm_beta.csv", @@ -537,9 +577,8 @@ 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. +and :class:`rocketpy.LinearGenericSurface` expose these as the ``interpolation`` +and ``extrapolation`` arguments. .. note:: Interpolation and extrapolation only apply to **tabulated** coefficients. @@ -562,31 +601,30 @@ Each argument accepts either: reference_area=np.pi * radius**2, reference_length=2 * radius, coefficients={ - "cD": "cD.csv", - "cL": "cL.csv", + "cA": "cA.csv", + "cN": "cN.csv", }, # A single method applied to every coefficient: extrapolation="constant", # ... or per coefficient (unlisted ones keep the default): - interpolation={"cD": "linear", "cL": "akima"}, + interpolation={"cA": "linear", "cN": "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. + **transonic drag rise** (:math:`Ma \approx 0.8`--:math:`1.2`), where a spline + will oscillate and invent non-physical wiggles in the axial coefficient + :math:`C_A`. 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. + **dense, smooth** data. - ``"spline"`` produces the smoothest derivatives but overshoots near sharp features (stall, :math:`Ma = 1`). Use it only for genuinely smooth, well-resolved data. @@ -605,7 +643,7 @@ about smooth derivatives. 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 @@ -621,7 +659,7 @@ an extreme condition beyond your data. 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 + :math:`C_A` or a moment slope to large, non-physical values right when the rocket is at an extreme condition. .. tip:: @@ -636,3 +674,103 @@ an extreme condition beyond your data. :meth:`rocketpy.Function.set_extrapolation` for the full list of methods. +.. _active_during: + +Activation Window +----------------- + +By default a surface produces aerodynamic force throughout the flight. The +``active_during`` argument restricts it to part of the flight. This is useful +for a surface that only exists (or only matters) during a phase. It is accepted +by both :class:`rocketpy.GenericSurface` and +:class:`rocketpy.LinearGenericSurface`, and accepts: + +- ``"always"`` (default): the surface always contributes force. +- ``"power_on"``: only while the motor is burning (up to burnout). +- ``"power_off"``: only after the motor has burned out. +- a callable ``active_during(t, flight)`` returning ``True`` when the surface is + active at time ``t`` (in seconds) of the given :class:`rocketpy.Flight`, for + any custom window. + +.. code-block:: python + + # base drag that only applies after burnout + base_drag = GenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={"cA": 0.4}, # axial (drag) coefficient + active_during="power_off", + ) + +This is also how a full-vehicle model captures the powered/coasting drag +difference: build one ``"power_on"`` and one ``"power_off"`` surface and add them +together (see :ref:`fullbodyaerodynamics`). + + +.. _fullbodyaerodynamics: + +Whole-vehicle aerodynamics +-------------------------- + +Instead of (or in addition to) modelling each component, you can describe the +**entire rocket** with a single generic surface that already carries the whole +vehicle's coefficients, for example a set exported from CFD, a wind tunnel or +OpenRocket. + +Adding a prebuilt full-vehicle model +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`rocketpy.Rocket.add_full_body_aerodynamics` adds such a surface (or a list +of them). Reference its coefficients to the rocket cross-section area and diameter +so it sums consistently with the rest of the vehicle: + +.. code-block:: python + + surface = GenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={...}, + ) + rocket.add_full_body_aerodynamics(surface) + +Because it is just another aerodynamic surface, a full-vehicle model can be +**mixed** with modelled add-on surfaces (e.g. a measured body plus modelled +canards). Pass ``overwrite=True`` to make it the rocket's **only** aerodynamics: +every existing aerodynamic surface is removed and both built-in drag curves are +cleared, so the supplied surface(s) provide the complete force set. + +A rocket's drag differs between powered and coasting flight (the motor plume +lowers the base drag). To capture this, build two surfaces, set each one's +``active_during`` to ``"power_on"`` and ``"power_off"``, and pass them as a list; +each then produces force only during its phase. + +Extracting a rocket's coefficients +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can also collapses an assembled rocket into a single +stability-derivative model about its center of dry mass: + +- :meth:`rocketpy.Rocket.to_coefficients` returns the coefficient curves as a + dict split into ``"power_off"`` and ``"power_on"`` sets (only the drag differs + between them), each mapping a coefficient name to a :class:`rocketpy.Function` + of Mach. +- :meth:`rocketpy.Rocket.to_surface` wraps those into a ready-to-use pair of + :class:`rocketpy.LinearGenericSurface` objects, one gated to each motor phase -- + the inverse of :meth:`~rocketpy.Rocket.add_full_body_aerodynamics`. + +.. code-block:: python + + coefficients = rocket.to_coefficients() # {"power_off": {...}, "power_on": {...}} + surfaces = rocket.to_surface() # [power_off surface, power_on surface] + + # a bare rocket carrying only this pair flies the same as the full model + bare.add_full_body_aerodynamics(surfaces, overwrite=True) + +.. important:: + The extracted model is a **linear summary tabulated only against Mach**: the + derivatives are taken at zero angle of attack, zero sideslip and zero rates, + so incidence/rate nonlinearity, Reynolds dependence and control-surface + dependence are dropped. These are exactly the assumptions of the built-in + Barrowman surfaces (nose cones, fins, and tails), so a rocket built only from + those is reproduced exactly. + See :ref:`aero_cp_stability` for the extraction math and its limitations. diff --git a/docs/user/rocket/rocket.rst b/docs/user/rocket/rocket.rst index 956175409..7a7636815 100644 --- a/docs/user/rocket/rocket.rst +++ b/docs/user/rocket/rocket.rst @@ -16,6 +16,5 @@ Rocket Usage .. toctree:: :maxdepth: 3 :caption: Generic Surfaces and Custom Aerodynamic Coefficients - + Generic Surfaces and Custom Aerodynamic Coefficients - \ No newline at end of file