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 new file mode 100644 index 000000000..80e7de108 --- /dev/null +++ b/docs/technical/aerodynamics/center_of_pressure_and_stability.rst @@ -0,0 +1,405 @@ +.. _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 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/docs/user/rocket/generic_surface.rst b/docs/user/rocket/generic_surface.rst index 997f5a178..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 ~~~~~~~~~~~~~~~~~~ @@ -199,9 +265,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} @@ -247,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:: @@ -435,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 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/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 e9b30da45..17d2e3a87 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,35 +23,117 @@ 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. + """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. + _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. - Returns - ------- - None + 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. """ - self.aero_surface.cl() + 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. + """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-curve slope (``clalpha``) of the aero surface as a + function of Mach number. Returns ------- None """ + self.aero_surface.clalpha() + + 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, @@ -136,9 +216,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 @@ -193,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) @@ -215,11 +294,12 @@ def all(self, *, filename=None): self.airfoil(filename=filename) self.roll(filename=filename) self.lift(filename=filename) + 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 @@ -274,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): @@ -295,6 +374,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): @@ -841,7 +921,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): @@ -849,7 +929,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): @@ -870,17 +950,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 - - -class _LinearGenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all linear generic surface plots.""" - - def draw(self, *, filename=None): - pass diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index e792acc9f..99a528fb7 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -1264,12 +1264,24 @@ 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.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 +1325,81 @@ 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 +1749,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 +1779,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 +1791,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 +1853,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..4cfc0934b 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,42 @@ 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, + ) + # 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 +179,51 @@ 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 +281,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 +320,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,16 +742,70 @@ 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) - ax.scatter( - cp, 0, label="Static Center of Pressure", color="red", s=10, zorder=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, @@ -724,12 +888,18 @@ 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() + # Non-axisymmetric rockets: the above describe the pitch plane only, so + # also show the yaw-plane margins. + if not self.rocket.is_axisymmetric: + self.static_margin_yaw() + self.stability_margin_yaw() # 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..12a2ba5c5 100644 --- a/rocketpy/prints/aero_surface_prints.py +++ b/rocketpy/prints/aero_surface_prints.py @@ -1,11 +1,58 @@ -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 + 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. @@ -18,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. @@ -51,9 +122,10 @@ def all(self): self.identity() self.geometry() self.lift() + self.coefficients() -class _NoseConePrints(_AeroSurfacePrints): +class _NoseConePrints(_BarrowmanSurfacePrints): """Class that contains all nosecone prints.""" def geometry(self): @@ -72,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("-------------------------------------") @@ -170,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("-------------------------------------") @@ -291,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): @@ -311,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): @@ -327,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): @@ -335,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.generic_surface.reference_area:.3f} m") - print(f"Reference length: {2 * self.generic_surface.rocket_radius:.3f} m") - - def all(self): - """Prints all information of the generic surface. - - Returns - ------- - None - """ - self.identity() - self.geometry() - self.lift() - - -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.generic_surface.reference_area:.3f} m") - print(f"Reference length: {2 * self.generic_surface.rocket_radius:.3f} m") - - def all(self): - """Prints all information of the linear generic surface. - - Returns - ------- - None - """ - self.identity() - self.geometry() - self.lift() 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..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, diff --git a/rocketpy/rocket/aero_surface/_barrowman_surface.py b/rocketpy/rocket/aero_surface/_barrowman_surface.py new file mode 100644 index 000000000..addc41364 --- /dev/null +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -0,0 +1,231 @@ +import numpy as np + +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 Barrowman-defined aerodynamic surfaces + such as nose cones, tails/transitions and fin sets. + + 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 + geometric cp through ``center_of_pressure``), and, for fins, set + ``self.roll_parameters = [clf_delta, cld_omega, cant_angle_rad]``. + """ + + # Geometry-defined Barrowman surfaces are axisymmetric by construction + # (``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 + + @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) + + 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 Matrix([[-1, 0, 0], [0, 1, 0], [0, 0, -1]]) + + def evaluate_coefficients(self): + """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 # normal-force-curve slope, a Function of Mach + + # 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.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: + 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 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 + 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",), + 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 new file mode 100644 index 000000000..264b49a84 --- /dev/null +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -0,0 +1,616 @@ +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 (such as lift or drag), stored using + only the variables it actually depends on.""" + + def __init__( + self, + source, + depends_on=None, + unsteady_aero=False, + control_variables=(), + name="coefficient", + extrapolation=None, + interpolation=None, + single_var=None, + ): + """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 : 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, 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 + 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 ``()``. + name : str, optional + A readable name for the coefficient (e.g. ``"cL_alpha"`` or + ``"Drag Coefficient with Power Off"``). It appears in error messages, + so a clear name makes problems easier to spot. Default + ``"coefficient"``. + extrapolation : str, optional + 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 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. + 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`` 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] + 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): + # 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) + # 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=interpolation or "linear", + extrapolation=extrapolation or "constant", + ) + 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 + + def _resolve_input(self, source, single_var): + """Infer ``(stored source, depends_on)`` from a raw coefficient input. + + 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 + n_vars = len(independent_vars) + + if isinstance(source, AeroCoefficient): + # An already-built coefficient passed straight through, re-keyed to + # this surface's variable order. 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 + + if isinstance(source, str): + if source.lower().endswith(".csv"): + return self._load_csv( + source, + name, + independent_vars, + extrapolation=self.extrapolation or "constant", + interpolation=self.interpolation or "linear", + single_var=single_var, + ) + # 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=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 " + "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="constant", + interpolation="linear", + 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 + ---------- + 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, used to validate + the CSV header columns. + extrapolation : str, optional + Extrapolation method for the loaded ``Function``. Defaults to + ``"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. + + 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:`_resolve_input`. + """ + independent_vars = list(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 {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=interpolation, + extrapolation=extrapolation, + ) + return csv_func, [single_var or independent_vars[0]] + + 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 {name} CSV: " + f"{invalid_columns}. Valid options are: {independent_vars}." + ) + + if header[-1] in independent_vars: + raise ValueError( + f"Last column in {name} CSV must be the coefficient" + " value, not an independent variable." + ) + + 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, + interpolation=interpolation, + ) + if csv_func is None: + csv_func = Function( + file_path, + interpolation=interpolation, + extrapolation=extrapolation, + ) + + return csv_func, ordered_present_columns + + @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() + # 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, single_var=None): + """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: + params = list(inspect.signature(func).parameters.values()) + except (TypeError, ValueError): # pragma: no cover - builtins + 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: + 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): + """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 variables the coefficient is called with.""" + return len(self.independent_vars) + + def get_value_opt(self, *args): + """Fast evaluation without input checking (mirrors + :meth:`Function.get_value_opt`). + + 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 + 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 __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, + interpolation=self.interpolation, + ) + + __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: + 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/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..0e7a5965f 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -6,12 +6,13 @@ 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 ---------- @@ -91,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". @@ -100,45 +101,73 @@ 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 +175,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..52ad1ae1f --- /dev/null +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -0,0 +1,204 @@ +from rocketpy.rocket.aero_surface.generic_surface import GenericSurface + + +class ControllableGenericSurface(GenericSurface): + """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 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 controls, in the order the coefficients expect them. + ControllableGenericSurface.control_state : dict + Current value of each control (starts at 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_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``. + # 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, + reference_length, + coefficients, + center_of_pressure=(0, 0, 0), + name="Controllable Generic Surface", + controls=("deflection",), + extrapolation=None, + interpolation=None, + ): + """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 + 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 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 + # the extended variable list (via the ``independent_vars`` property, + # which appends ``control_variables``) and the current control values. + self.control_variables = list(controls) + 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, + extrapolation=extrapolation, + interpolation=interpolation, + ) + # ``self.prints``/``self.plots`` are the generic ones wired by the base. + + 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( # pylint: disable=unused-argument + self, include_outputs=False, **kwargs + ): + return { + "reference_area": self.reference_area, + "reference_length": self.reference_length, + "coefficients": { + "cN": self.cN, + "cY": self.cY, + "cA": self.cA, + "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..9b2b7a536 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_stability_derivatives() + 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): @@ -292,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) @@ -308,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 f809bca29..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__( @@ -175,8 +176,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/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 5fec8a099..c9ca3e490 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -81,16 +81,22 @@ 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, 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, @@ -148,6 +154,12 @@ 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 to + fin rotation matrices.""" + super()._update_geometry_chain() + self.evaluate_rotation_matrix() + @property def cant_angle(self): return self._cant_angle @@ -203,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. @@ -318,6 +323,35 @@ 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): + """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): + """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.cN_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach) * sin_sq + ) + self.cY_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach) * cos_sq + ) + def compute_forces_and_moments( self, stream_velocity, @@ -344,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 ------- @@ -363,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]) @@ -438,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 b61bfcc19..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. @@ -200,11 +194,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 +252,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): @@ -335,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 5099d322b..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 @@ -172,8 +173,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/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_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py index c58055945..49e594ec1 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) @@ -220,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/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 4b83e0e4f..decb832b6 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,11 +1,94 @@ import copy -import csv +import inspect import math import numpy as np from rocketpy.mathutils import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector +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, +) + + +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: @@ -14,6 +97,10 @@ 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. + # ``False`` for a generic surface (its coefficients may differ between planes) + is_axisymmetric = False + def __init__( self, reference_area, @@ -21,6 +108,10 @@ def __init__( coefficients, 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 @@ -35,6 +126,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 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`. @@ -48,34 +152,81 @@ 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. 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). """ + 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 + ) + self.reference_area = reference_area self.reference_length = reference_length self.center_of_pressure = center_of_pressure @@ -85,15 +236,336 @@ 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 + ) + # 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 = self._process_input(coeff_value, coeff) + 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() + + # 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. 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. + + 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 + 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_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. + + 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 + """ + 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, + 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_coeff, moment_coeff): + def cp_z(mach): + 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 + 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)") + + 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. + + ``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 _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. With no force + coefficients to infer from, the canonical body frame is assumed. + """ + 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 "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}." + ) + 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 @@ -104,9 +576,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, @@ -173,6 +645,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. @@ -192,45 +666,81 @@ 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)``. + 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 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, ) + # 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( - 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 r1, r2, r3, pitch, yaw, roll - 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, @@ -243,6 +753,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 @@ -271,6 +783,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 ------- @@ -295,192 +813,31 @@ def compute_forces_and_moments( alpha = np.arctan2(stream_velocity[1], stream_velocity[2]) beta = np.arctan2(stream_velocity[0], stream_velocity[2]) - # Compute aerodynamic forces and moments - lift, side, drag, pitch, yaw, roll = self._compute_from_coefficients( + # Non-dimensionalize the body angular rates into the conventional reduced + # 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 + ) + + # 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, beta, stream_mach, reynolds, - omega[0], # q - omega[1], # r - omega[2], # p - ) - - # Conversion from aerodynamic frame to body frame - 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)], - ] + 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, ) - 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])) 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. - - Parameters - ---------- - input_data : str or callable - Input data to be processed, either a path to a CSV or a callable. - 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). - """ - 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." - ) - - 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. - - This loader expects header-based CSV data with one or more independent - variables among: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, - roll_rate. - """ - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - 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", - ) - - 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", - ) diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index 3e7ed9a55..69aa5442e 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -1,13 +1,20 @@ +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 +# 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 +23,9 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), 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 @@ -29,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`. @@ -42,59 +59,70 @@ 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). 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 + 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 +182,46 @@ 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). + 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__( @@ -164,6 +230,9 @@ def __init__( coefficients=coefficients, center_of_pressure=center_of_pressure, name=name, + extrapolation=extrapolation, + interpolation=interpolation, + force_convention=force_convention, ) self.compute_all_coefficients() @@ -171,6 +240,18 @@ def __init__( self.prints = _LinearGenericSurfacePrints(self) self.plots = _LinearGenericSurfacePlots(self) + def _evaluate_stability_derivatives(self): + """Build the center-of-pressure accessors for the linear model. + + 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 @@ -181,24 +262,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, @@ -220,82 +301,287 @@ 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 + # 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." ) - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], + 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 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 - ) + def _combine(self, first, second, sign, name): + """Return a coefficient equal to ``first + sign * second``. - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], + 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", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + + 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). + + 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) + 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): + """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. + + 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) + 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.""" # 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 @@ -312,6 +598,13 @@ def compute_all_coefficients(self): ) self.cld = self.compute_damping_coefficient(self.cl_p, self.cl_q, self.cl_r) + 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, rho, @@ -323,10 +616,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 @@ -342,69 +640,53 @@ 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)``. + 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 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 - ) + 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 + # 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( - 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 + yaw_side = dyn_pressure_area * ( + self.cYf.get_value_opt(*args) + self.cYd.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 + 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 - 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 + # 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( - 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) + 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) + 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 240a61a5c..7f17c159b 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 @@ -65,11 +65,12 @@ class NoseCone(AeroSurface): 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. @@ -129,7 +130,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 +166,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) @@ -464,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): @@ -551,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/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index 7d3a9bd30..a6fc75b56 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 @@ -28,6 +27,10 @@ class RailButtons(AeroSurface): 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, @@ -53,14 +56,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 +81,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..7ccd2e1e3 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 @@ -41,10 +41,12 @@ class Tail(AeroSurface): 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 @@ -76,7 +78,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 +91,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) @@ -172,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): @@ -218,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/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 c16c799ce..821eb884d 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 @@ -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. @@ -133,11 +149,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. - See :doc:`Positions and Coordinate Systems ` - for more information. + 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. ``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 @@ -162,12 +179,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 @@ -345,32 +364,58 @@ 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( + 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)", ) + # 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 - 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 + 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), @@ -412,10 +457,11 @@ 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 + self._cp_outdated = True + self._margin_outdated = True + # Flag for rocket non-axisymmetric warning. Used to show warning once. + self._axisymmetry_warned = False # Initialize plots and prints object self.prints = _RocketPrints(self) @@ -609,43 +655,340 @@ 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. + + 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 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 (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, 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), 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 - self.total_lift_coeff_der.set_source(lambda mach: 0) - self.cp_position.set_source(lambda mach: 0) - - # Calculate total lift coefficient derivative and center of pressure + # 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) + + # 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) + # 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 + self._aerodynamic_center += ( + ref_factor * lift_coeff_der * (position.z - self._csys * cp_z) + ) + + # Yaw plane. + 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 generic 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 + # 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 + + # 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() + 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): + """Largest pitch- vs yaw-plane aerodynamic center difference, in meters. + 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 + # 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) + - 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``).""" + # Nose, tail and fin sets contribute identically to both planes. + 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) + + @property + def cp_position(self): + """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. + """ + # 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 + 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 @@ -674,11 +1017,18 @@ 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. 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", + 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 @@ -694,19 +1044,32 @@ 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: ( + ( + ( + self.center_of_mass.get_value_opt(time) + - 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.cp_position.get_value_opt(mach) + - self.aerodynamic_center_yaw.get_value_opt(mach) ) / (2 * self.radius) ) * 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. @@ -719,24 +1082,42 @@ 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) - - self.cp_position.get_value_opt(0) + - self.aerodynamic_center.get_value_opt(0) ) / (2 * self.radius) ) ) # 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 ) - return self.static_margin + + # 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): """Calculates and returns the rocket's dry inertias relative to @@ -1066,10 +1447,9 @@ 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 CM (and the margins) + self._margin_outdated = True self.evaluate_com_to_cdm_function() self.evaluate_nozzle_gyration_tensor() @@ -1148,9 +1528,76 @@ 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 + 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" + ): + """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. @@ -2230,7 +2677,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 +2690,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 +2742,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 @@ -2368,271 +2817,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/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/flight.py b/rocketpy/simulation/flight.py index baff48a38..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. @@ -2305,24 +2309,205 @@ 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 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 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. """ 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 + ] + + # 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 + 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.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) + ) + 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/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_derivatives.py b/rocketpy/simulation/helpers/flight_derivatives.py index 1426e2cd3..dcd275076 100644 --- a/rocketpy/simulation/helpers/flight_derivatives.py +++ b/rocketpy/simulation/helpers/flight_derivatives.py @@ -47,6 +47,79 @@ 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 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] * reduced, + omega[1] * reduced, + omega[2] * reduced, + ) + ) + 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 +355,17 @@ 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 +596,19 @@ 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 +841,19 @@ 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 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 ------- 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/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/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_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..ebc00c72a --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py @@ -0,0 +1,164 @@ +"""Regression tests for the GenericSurface-rooted aerodynamic hierarchy. + +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 + +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`` 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 + ) + 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 derivative must equal the Barrowman clalpha. + assert pytest.approx( + 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) + + +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={ + "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", + ) + 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={"cA_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={ + "cN_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.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", + ) + + 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_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 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 _BarrowmanSurface.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..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,27 +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)}, ) - 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.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""" @@ -117,3 +236,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..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,21 +1,24 @@ 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", [ - "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 +40,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 +48,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 +73,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)}, ) @@ -91,3 +94,138 @@ 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) + + +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 + ) 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/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..02fbed668 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( @@ -142,11 +142,13 @@ 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) - 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( @@ -182,11 +184,13 @@ 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) - 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..b2995a514 --- /dev/null +++ b/tests/unit/rocket/test_stability_rework.py @@ -0,0 +1,94 @@ +"""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["cN"] + 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 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) == {"cN", "cY", "cA", "cm", "cn", "cl"} + + 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) + ) + + +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_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} + ) + + 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) + ) + # 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_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..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, ):