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/CHANGELOG.md b/CHANGELOG.md index 1cdd8479c..c46318b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Public controller class hierarchy: `Controller` (an `Event` subclass), `SurfaceController` (drives `ControllableGenericSurface` objects), `AirBrakesController`, and `ScheduledController` (open-loop replay of a recorded control schedule). +- ENH: Controllers automatically record the control state of their controlled objects after every execution (`control_history` / `recorded_schedule`); `AirBrakesController.deployment_level_history` exposes the deployment level as a `Function` of time, with dedicated prints/plots. New `Flight.controllers` property. +- ENH: `Rocket.add_controllable_surface` wires a `ControllableGenericSurface` into the aerodynamic surfaces and registers a `SurfaceController` driving it. +- ENH: `Rocket.add_air_brakes` gains a `position` parameter; when given, the air-brake force is applied at that station (producing moments at nonzero incidence). Without it, the force stays pinned to the center of dry mass, matching historical results. +- ENH: Controller serialization records the controlled objects' names and (optionally) the recorded control history; loading a rocket rewires controllers by name, and a controller whose pickled function cannot be restored falls back to a `ScheduledController` replaying its recorded schedule. - ENH: MNT: introduce pressure unit conversion when using forecast/reanalysis/ensemble data [#955](https://github.com/RocketPy-Team/RocketPy/pull/955) - ENH: Auto Populate Changelog [#919](https://github.com/RocketPy-Team/RocketPy/pull/919) - ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) @@ -39,10 +44,26 @@ Attention: The newest changes should be on top --> ### Changed -- +- ENH: Air brakes are now summed in the standard aerodynamic-surface loop instead of a dedicated drag-only branch. Results are equivalent at the default (pinned) position except that drag is decomposed along the freestream like every other surface, adding small lateral force components at nonzero angle of attack (previously pure axial). `override_rocket_drag` now suppresses the rocket body drag while the brakes are deployed. +- ENH: Controller functions must accept keyword arguments only (`def controller_function(**kwargs)`). `Rocket.add_air_brakes` is the one exception: it still adapts legacy positional signatures with a `DeprecationWarning`. +- ENH: `GenericSurface`/`LinearGenericSurface` coefficients now receive the conventional **non-dimensional reduced** angular rates (`q* = q·L_ref/(2V)`, etc.) instead of the raw body rates in rad/s. **Breaking** for nonlinear `GenericSurface` coefficient tables that depend on `pitch_rate`/`yaw_rate`/`roll_rate` and were built against raw rad/s; rebuild them against the reduced rates. Barrowman surfaces and `LinearGenericSurface` results are unchanged. + +### Deprecated + +- Legacy positional air-brakes controller functions and the `initial_observed_variables` argument of `Rocket.add_air_brakes` (use `context={"observed_variables": [...]}`); scheduled for removal in v1.13. + +### Removed + +- ENH: Removed the private `_Controller` class from the public API; use the public `Controller` (or a typed subclass) instead. Direct construction now rejects positional controller-function signatures. +- ENH: Removed the singular `Rocket.center_of_pressure(alpha, beta, mach)` method. The force-application center of pressure is undefined at zero normal force; when needed it can be reconstructed on demand from `Rocket.aerodynamic_coefficients_full` as `x_cdm + csys·d·Cm/CN`. The well-conditioned, slope-based center of pressure remains available as `Rocket.aerodynamic_center` (aliased by the deprecated `cp_position`). ### Fixed +- BUG: Loading a saved rocket that had controllers or air brakes raised `AttributeError` (dead `interactive_objects` references); controllers are now rewired to the decoded objects by name, and air brakes are serialized once (inside `aerodynamic_surfaces`) preserving controller identity. +- BUG: `controller.info()` crashed on the removed `interactive_objects` attribute. +- BUG: The context dictionary mutated by a controller function was never reset between simulations (and diverged from the event's reset copy); as an `Event` subclass the controller now has a single context restored at the start of each flight. +- BUG: Monte Carlo air-brake controllers shared nested mutable context state across samples (shallow copy); contexts are now deep-copied per sample. +- BUG: Only air brakes were reset between flights; every controlled object (e.g. a `ControllableGenericSurface` deflection) is now restored to its initial control state by `Controller.reset()`. - BUG: Add wraparound logic for wind direction in environment plots [#939](https://github.com/RocketPy-Team/RocketPy/pull/939) ## [v1.12.1] - 2026-04-03 diff --git a/docs/reference/classes/AirBrakesController.rst b/docs/reference/classes/AirBrakesController.rst new file mode 100644 index 000000000..7cc475636 --- /dev/null +++ b/docs/reference/classes/AirBrakesController.rst @@ -0,0 +1,5 @@ +AirBrakesController Class +------------------------- + +.. autoclass:: rocketpy.AirBrakesController + :members: diff --git a/docs/reference/classes/Controller.rst b/docs/reference/classes/Controller.rst new file mode 100644 index 000000000..663edc286 --- /dev/null +++ b/docs/reference/classes/Controller.rst @@ -0,0 +1,5 @@ +Controller Class +---------------- + +.. autoclass:: rocketpy.Controller + :members: diff --git a/docs/reference/classes/ScheduledController.rst b/docs/reference/classes/ScheduledController.rst new file mode 100644 index 000000000..8bef34f72 --- /dev/null +++ b/docs/reference/classes/ScheduledController.rst @@ -0,0 +1,5 @@ +ScheduledController Class +------------------------- + +.. autoclass:: rocketpy.ScheduledController + :members: diff --git a/docs/reference/classes/SurfaceController.rst b/docs/reference/classes/SurfaceController.rst new file mode 100644 index 000000000..c008206b5 --- /dev/null +++ b/docs/reference/classes/SurfaceController.rst @@ -0,0 +1,5 @@ +SurfaceController Class +----------------------- + +.. autoclass:: rocketpy.SurfaceController + :members: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index e86240dd9..c06b45cac 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -17,6 +17,10 @@ This reference manual details functions, modules, methods and attributes include classes/sensors/index.rst classes/Flight classes/Event + classes/Controller + classes/SurfaceController + classes/AirBrakesController + classes/ScheduledController Utilities classes/EnvironmentAnalysis Monte Carlo Analysis 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/airbrakes.rst b/docs/user/airbrakes.rst index dc468e8e0..6d1ab88df 100644 --- a/docs/user/airbrakes.rst +++ b/docs/user/airbrakes.rst @@ -106,8 +106,9 @@ Defining the Controller Function Lets start by defining a very simple controller function. -The ``controller_function`` must take in the following arguments, in this -order: +The ``controller_function`` must accept keyword arguments only +(``def controller_function(**kwargs)``). At every call, the following keys +(among others, see :class:`rocketpy.Controller`) are available: 1. ``time`` (float): The current simulation time in seconds. 2. ``sampling_rate`` (float): The rate at which the controller @@ -144,13 +145,12 @@ order: a list of lists, with each sublist containing a state vector. The last item in the list always corresponds to the previous state vector, providing a chronological sequence of the rocket's - evolving states. -5. ``observed_variables`` (list): A list containing the variables that - the controller function returns. The return of each controller - function call is appended to the observed_variables list. The - initial value in the first step of the simulation of this list is - provided by the ``initial_observed_variables`` argument. -6. ``air_brakes`` (AirBrakes): The ``AirBrakes`` instance being controlled. + evolving states. Only available when declared through the + ``controller_needs`` argument. +5. ``air_brakes`` (AirBrakes): The ``AirBrakes`` instance being controlled. +6. ``controller`` (:class:`rocketpy.AirBrakesController`): The controller + itself, giving access to its persistent ``context`` dictionary and its + execution ``log`` (the values returned by previous calls). Our example ``controller_function`` will deploy the air brakes when the rocket reaches 1500 meters above the ground. The deployment level will be function of the @@ -230,8 +230,9 @@ Lets define the controller function: .. note:: - - The ``controller_function`` accepts 6, 7, or 8 parameters for backward - compatibility: + - **Deprecated:** for backward compatibility, ``add_air_brakes`` (and only + it) still accepts legacy positional controller functions with 6, 7, or 8 + parameters: * **6 parameters** (original): ``time``, ``sampling_rate``, ``state``, ``state_history``, ``observed_variables``, ``air_brakes`` @@ -239,6 +240,10 @@ Lets define the controller function: * **8 parameters** (with environment): adds ``sensors`` and ``environment`` as the 7th and 8th parameters + A ``DeprecationWarning`` is emitted; migrate to the ``**kwargs`` form. + Constructing a :class:`rocketpy.Controller` directly requires the + ``**kwargs`` form. + - The **environment parameter** provides access to atmospheric conditions (wind, temperature, pressure, elevation) without relying on global variables. This enables proper serialization of rockets with air brakes and improves @@ -353,14 +358,14 @@ controller function. If you want to disable this feature, set ``clamp`` to .. jupyter-execute:: - air_brakes = calisto.add_air_brakes( + air_brakes, controller = calisto.add_air_brakes( drag_coefficient_curve="../data/rockets/calisto/air_brakes_cd.csv", controller_function=controller_function, sampling_rate=10, reference_area=None, clamp=True, - initial_observed_variables=[0, 0, 0], override_rocket_drag=False, + return_controller=True, name="Air Brakes", controller_needs=["state_history"], ) @@ -369,10 +374,18 @@ controller function. If you want to disable this feature, set ``clamp`` to .. note:: - The ``initial_observed_variables`` argument is optional. It is used as - the initial value for the ``observed_variables`` list passed on the - ``controller_function`` at the first time step. If not given, the - ``observed_variables`` list will be initialized as an empty list. + The air brakes join the rocket's ``aerodynamic_surfaces`` and are summed + in the equations of motion like any other surface. The optional + ``position`` argument sets the station at which the drag force is + applied, producing the corresponding moments at nonzero angle of attack; + without it, the force is applied at the center of dry mass (zero moment + arm), matching the historical behavior. + + ``return_controller=True`` also returns the + :class:`rocketpy.AirBrakesController`, which records the deployment level + during flight (see below). The ``initial_observed_variables`` argument is + deprecated; seed persistent controller state through + ``context={"observed_variables": [...]}`` instead. .. seealso:: @@ -400,15 +413,26 @@ rocket reaches apogee, and we will save some time. Analyzing the Results --------------------- -Now we can create some plots to analyze the results. We rely on the -``observed_variables`` list to get the data we want to plot. Since we returned -the ``time``, ``deployment_level`` and the ``drag_coefficient`` in the -``controller_function``, the ``observed_variables`` list will contain these -values at every time step. +The controller automatically records the deployment level of the air brakes +at every execution. After the flight, the recorded history is available both +as raw samples (``controller.control_history``) and as a ``Function`` of time +(``controller.deployment_level_history``), with ready-made plots: + +.. jupyter-execute:: -We can retrieve the ``observed_variables`` list by calling the -``get_controller_observed_variables`` method of the ``Flight`` instance. -Then we can plot the data we want. + controller.plots.deployment_level_curve() + +.. jupyter-execute:: + + controller.deployment_level_history + +The controllers of a flight are also reachable through +``test_flight.controllers``. + +For any other quantity of interest, return it from the +``controller_function``: every return value is appended to the controller's +``log``. Since we returned the ``time``, ``deployment_level`` and the +``drag_coefficient``, we can plot the drag coefficient like this: .. jupyter-execute:: @@ -416,21 +440,11 @@ Then we can plot the data we want. time_list, deployment_level_list, drag_coefficient_list = [], [], [] - obs_vars = test_flight._controllers[0].log - - for time, deployment_level, drag_coefficient in obs_vars: + for time, deployment_level, drag_coefficient in controller.log: time_list.append(time) deployment_level_list.append(deployment_level) drag_coefficient_list.append(drag_coefficient) - # Plot deployment level by time - plt.plot(time_list, deployment_level_list) - plt.xlabel("Time (s)") - plt.ylabel("Deployment Level") - plt.title("Deployment Level by Time") - plt.grid() - plt.show() - # Plot drag coefficient by time plt.plot(time_list, drag_coefficient_list) plt.xlabel("Time (s)") @@ -444,6 +458,38 @@ Then we can plot the data we want. For more information on the :class:`rocketpy.AirBrakes` class attributes, see :class:`rocketpy.AirBrakes` section. +Replaying the Recorded Control +------------------------------ + +The recorded schedule can be replayed open-loop with a +:class:`rocketpy.ScheduledController`, re-flying the same control inputs +without the original controller function. This is useful to reconstruct a +simulation (e.g. after loading a saved flight whose controller function is +not available) or to study the plant response in isolation: + +.. code-block:: python + + from rocketpy import ScheduledController + + replay_controller = ScheduledController.from_controller(controller) + + # Wire it to a rocket configured the same way (or reuse the same rocket) + # by replacing the original controller before re-simulating: + calisto._controllers = [replay_controller] + + replay_flight = Flight( + rocket=calisto, + environment=env, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=True, + ) + +The replayed deployment schedule is interpolated linearly between the +recorded samples and applied through ``set_control``, so clamping still +applies. + And of course, we should check some of the simulation results: .. jupyter-execute:: diff --git a/docs/user/event_usage.rst b/docs/user/event_usage.rst index d59b670d4..4502f7ff2 100644 --- a/docs/user/event_usage.rst +++ b/docs/user/event_usage.rst @@ -22,6 +22,17 @@ They can: If you are looking for the lower-level simulation loop that consumes events and time nodes, see :doc:`../technical/phases_and_nodes`. +.. note:: + **Controllers are Events.** :class:`rocketpy.Controller` (and its + specializations :class:`rocketpy.SurfaceController`, + :class:`rocketpy.AirBrakesController` and + :class:`rocketpy.ScheduledController`) is an :class:`rocketpy.Event` + subclass: a periodic, trigger-less event with ``changes_dynamics=True`` + that mutates its controlled objects each sample and automatically records + their control state into ``control_history``. Everything on this page + (context, ``needs``, ``disable_on``/``enable_on``, commands) applies to + controllers as well. + .. important:: **Performance Considerations** @@ -147,8 +158,9 @@ The following parameters are always or conditionally available: **Custom parameters:** -- Any additional key-value pairs defined in the event's ``context`` dictionary - are unpacked and passed as separate ``kwargs``. +- Values stored in the event's ``context`` dictionary are accessed through + ``kwargs["event"].context`` inside the trigger and callback; they are not + unpacked into separate ``kwargs``. Understanding Event Parameters ------------------------------ diff --git a/docs/user/rocket/generic_surface.rst b/docs/user/rocket/generic_surface.rst index 997f5a178..bf9bdcfd0 100644 --- a/docs/user/rocket/generic_surface.rst +++ b/docs/user/rocket/generic_surface.rst @@ -199,9 +199,25 @@ The coefficients are all functions of: - Side slip angle (:math:`\beta`) in radians. - Mach number (:math:`Ma`). - Reynolds number (:math:`Re`). -- Pitch rate (:math:`q`) in radians per second. -- Yaw rate (:math:`r`) in radians per second. -- Roll rate (:math:`p`) in radians per second. +- Pitch rate (:math:`q^{*}`), non-dimensional (reduced). +- Yaw rate (:math:`r^{*}`), non-dimensional (reduced). +- Roll rate (:math:`p^{*}`), non-dimensional (reduced). + +.. important:: + The angular rates are the conventional **non-dimensional reduced rates**, not + the raw body rates in rad/s: + + .. math:: + q^{*} = \frac{q \, L_{ref}}{2 V}, \quad + r^{*} = \frac{r \, L_{ref}}{2 V}, \quad + p^{*} = \frac{p \, L_{ref}}{2 V} + + where :math:`L_{ref}` is the surface reference length and :math:`V` the + freestream speed. This matches how published and tool-generated aerotables + (Missile DATCOM, OpenVSP, CFD/wind-tunnel sweeps) tabulate rate derivatives, + so such tables can be used directly. RocketPy non-dimensionalizes the body + rates internally before evaluating the coefficients (the factor is 0 at zero + airspeed). Define your tables against the reduced rates. .. math:: \begin{aligned} diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index ae602d784..f83847dd6 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -1,5 +1,11 @@ from ._logging import enable_logging, logger, set_log_level -from .control import _Controller +from .control import ( + AirBrakesController, + ControlledObject, + Controller, + ScheduledController, + SurfaceController, +) from .environment import Environment, EnvironmentAnalysis from .mathutils import ( Function, @@ -30,12 +36,15 @@ AeroSurface, AirBrakes, Components, + ControllableGenericSurface, + Effector, EllipticalFin, EllipticalFins, Fin, Fins, FreeFormFin, FreeFormFins, + GenericEffector, GenericSurface, LinearGenericSurface, NoseCone, diff --git a/rocketpy/control/__init__.py b/rocketpy/control/__init__.py index 0fa89380b..f98c8c3f4 100644 --- a/rocketpy/control/__init__.py +++ b/rocketpy/control/__init__.py @@ -1 +1,5 @@ -from .controller import _Controller +from .air_brakes_controller import AirBrakesController +from .controlled_object import ControlledObject +from .controller import Controller +from .scheduled_controller import ScheduledController +from .surface_controller import SurfaceController diff --git a/rocketpy/control/air_brakes_controller.py b/rocketpy/control/air_brakes_controller.py new file mode 100644 index 000000000..a11839283 --- /dev/null +++ b/rocketpy/control/air_brakes_controller.py @@ -0,0 +1,98 @@ +from ..plots.controller_plots import _AirBrakesControllerPlots +from ..prints.controller_prints import _AirBrakesControllerPrints +from .surface_controller import SurfaceController + + +class AirBrakesController(SurfaceController): + """A :class:`SurfaceController` specialized for a single + :class:`rocketpy.AirBrakes` surface. + + The air brakes are exposed to the controller function as + ``kwargs["air_brakes"]``; the controller applies control by setting + ``air_brakes.deployment_level``. The recorded deployment history is + available post-flight through :attr:`deployment_level_history`. + + See Also + -------- + rocketpy.Rocket.add_air_brakes : Builds the air brakes and this + controller in one call. + """ + + def __init__( + self, + controller_function, + air_brakes, + sampling_rate, + context=None, + name="AirBrakes Controller", + enabled=True, + disable_on=None, + enable_on=None, + needs=None, + ): + """Initialize the air brakes controller. + + Parameters + ---------- + controller_function : callable + Control logic with signature ``controller_function(**kwargs)``; + set ``kwargs["air_brakes"].deployment_level`` to apply the + control action. See :class:`Controller` for the full list of + available keyword arguments. + air_brakes : AirBrakes + The air brakes surface this controller drives. + sampling_rate : float + Rate in hertz at which the controller executes. + context : dict, optional + Initial persistent state; see :class:`Controller`. + name : str, optional + Controller name. Defaults to ``"AirBrakes Controller"``. + enabled : bool, optional + Initial enabled state. Defaults to ``True``. + disable_on, enable_on : str or int or float or callable, optional + Automatic disable/enable conditions; see :class:`Controller`. + needs : list or frozenset of str or None, optional + Expensive simulation values the controller function accesses; + see :class:`Controller`. + """ + super().__init__( + controller_function, + air_brakes, + sampling_rate, + context=context, + name=name, + controlled_objects_name="air_brakes", + enabled=enabled, + disable_on=disable_on, + enable_on=enable_on, + needs=needs, + ) + self.prints = _AirBrakesControllerPrints(self) + self.plots = _AirBrakesControllerPlots(self) + + @classmethod + def _construct( + cls, controller_function, controlled_objects, sampling_rate, **kwargs + ): + # __init__ pins controlled_objects_name to "air_brakes" itself. + kwargs.pop("controlled_objects_name", None) + return cls(controller_function, controlled_objects, sampling_rate, **kwargs) + + @property + def air_brakes(self): + """The controlled AirBrakes surface.""" + return self._controlled_objects_list[0] + + @property + def deployment_level_history(self): + """Recorded deployment level as a ``Function`` of time (linear + interpolation, constant extrapolation). Raises if no history has been + recorded yet.""" + schedule = self.recorded_schedule + try: + return schedule["air_brakes"]["deployment_level"] + except KeyError as exc: + raise ValueError( + "No recorded deployment level history - run a Flight with " + "this controller first." + ) from exc diff --git a/rocketpy/control/controlled_object.py b/rocketpy/control/controlled_object.py new file mode 100644 index 000000000..6f98d7a0b --- /dev/null +++ b/rocketpy/control/controlled_object.py @@ -0,0 +1,42 @@ +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ControlledObject(Protocol): + """Structural type for objects that a :class:`Controller` can drive. + + Any object exposing this interface can be handed to a controller as a + controlled object: the controller records its ``control_state`` after + every execution (see ``Controller.control_history``) and restores it to + ``initial_control_state`` at the start of each simulation. + + The interface is intentionally not tied to aerodynamic surfaces so that + non-surface actuators (e.g. a future thrust-vector-control gimbal on a + motor mount) can conform to it. ``ControllableGenericSurface`` (and thus + ``AirBrakes``) is the reference implementation. + + Notes + ----- + Controllers duck-type on this interface (they check for the presence of + ``control_state`` and ``_reset``) rather than requiring inheritance; + this class exists for documentation, typing, and optional ``isinstance`` + checks. + """ + + control_variables: list + """Ordered names of the control axes.""" + + control_state: dict + """Current value of each control variable.""" + + initial_control_state: dict + """Control values to restore at the start of each simulation.""" + + def set_control(self, name, value): + """Set the current value of a control variable (applying clamping).""" + + def get_control(self, name): + """Return the current value of a control variable.""" + + def _reset(self): + """Restore ``control_state`` to ``initial_control_state``.""" diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index 204cc028a..1d0bcf378 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -1,26 +1,33 @@ import warnings +from importlib import import_module from inspect import signature +import numpy as np + +from rocketpy.mathutils.function import Function from rocketpy.simulation.events.event import Event from rocketpy.tools import from_hex_decode, to_hex_encode +from ..plots.controller_plots import _ControllerPlots from ..prints.controller_prints import _ControllerPrints -class _Controller: +class Controller(Event): """A controller that modifies rocket state during simulation. - Controllers execute at a fixed sampling rate and can mutate rocket - objects (e.g. air brakes, fins) during flight. Like :class:`Event` - objects, controllers use a callback pattern with persistent ``context``, - but they explicitly expect external object state to change. + A ``Controller`` is an :class:`Event` that executes at a fixed sampling + rate, always fires (no trigger), and is expected to change the simulation + dynamics (``changes_dynamics=True``, ``priority=3``). At each execution + the user-supplied ``controller_function`` reads the simulation state and + mutates the ``controlled_objects`` (e.g. an air brakes instance) to apply + control actions. - Internally a controller is a thin wrapper around an :class:`Event`: it - builds an event (see :meth:`to_event`) whose callback invokes the - user-supplied ``controller_function``. The wrapping event is created with - ``changes_dynamics=True``, ``trigger_only_once=False``, and - ``priority=3``, and it mirrors the controller's ``enabled`` flag, - ``context``, ``sampling_rate``, ``disable_on`` and ``enable_on`` settings. + After every execution, the controller automatically records the + ``control_state`` of each controlled object that exposes one (see + :class:`rocketpy.control.controlled_object.ControlledObject`) into + :attr:`control_history`, timestamped with the simulation time. This + history powers post-flight prints/plots and open-loop replay through + ``ScheduledController``. The controller function is responsible for: @@ -29,20 +36,19 @@ class _Controller: 3. Mutating ``controlled_objects`` to apply those actions, 4. Returning logging information (appended to :attr:`log`). - The key difference from :class:`Event` is that object mutations are - intentional and expected -- this is the controller's primary purpose. - Attributes ---------- - event : Event - The wrapping :class:`Event` consumed by the simulation loop. - log : list + Controller.controlled_objects : object or list + Object(s) the controller modifies, held by reference. + Controller.control_history : dict + ``{object_name: {variable: [(time, value), ...]}}`` recorded during + the last simulation. Cleared at the start of each flight. + Controller.log : list Per-execution return values of ``controller_function`` (alias - :attr:`return_log`). Backed by the wrapped event's ``callback_log``. - enabled : bool - Current enabled state, mirrored from the wrapped event. - context : dict - Persistent state shared across executions. + :attr:`return_log`); same list as ``callback_log``. + Controller.context : dict + Persistent state shared across executions, restored to its + construction-time snapshot at the start of each flight. """ def __init__( @@ -56,7 +62,7 @@ def __init__( enabled=True, disable_on=None, enable_on=None, - controller_needs=None, + needs=None, ): """Initialize the controller. @@ -67,6 +73,7 @@ def __init__( ``controller_function(**kwargs) -> dict or None``. Invoked once per sample; its return value is appended to :attr:`log`. Mutate ``controlled_objects`` directly to apply control actions. + Functions with positional parameters are rejected. The following keys are always available in ``kwargs``: ``time`` (float, s), ``state`` (list ``[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]``), @@ -78,29 +85,30 @@ def __init__( ``phase`` (current flight phase), ``step_size`` (float, s), ``height_agl`` (float, m), - ``event`` (:class:`Event` wrapping this controller), + ``event`` (this :class:`Controller` instance), ``sampling_rate`` (float, Hz), - ``controller`` (this :class:`_Controller` instance), + ``controller`` (this :class:`Controller` instance), ``controlled_objects`` (the object(s) to mutate). If ``controlled_objects_name`` was set, those friendly names are also injected (plus ``controlled_objects_by_name`` for lists). - The following keys are only injected when declared via - ``controller_needs``: + The following keys are only injected when declared via ``needs``: ``pressure`` (float, Pa), ``state_dot`` (list, time derivative of ``state``), ``state_history`` (list of past state vectors). controlled_objects : object or list of object Object(s) the controller is allowed to modify (e.g. an air brakes instance). May be a single object or a list. They are held by - reference, so mutations persist in the simulation. + reference, so mutations persist in the simulation. Objects that + expose a ``control_state`` are automatically tracked in + :attr:`control_history` and reset at the start of each flight. sampling_rate : float Rate in hertz at which the controller executes; it runs every ``1 / sampling_rate`` seconds. context : dict, optional - Initial persistent state, passed to ``controller_function`` and - mutated in place to carry data across executions. The same dict is - shared with the wrapped event's ``context``. Defaults to an empty - dict. + Initial persistent state, accessed inside the controller function + via ``kwargs["controller"].context`` and mutated in place to carry + data across executions. Restored to this initial snapshot at the + start of each flight. Defaults to an empty dict. name : str, optional Human-readable controller name, used for identification and logging. Defaults to ``"Controller"``. @@ -114,189 +122,213 @@ def __init__( Names must not collide with reserved callback keywords. Defaults to ``None`` (no friendly binding). enabled : bool, optional - Initial enabled state of the wrapped event. If ``False``, the - controller does not execute until re-enabled, either via the - ``enable`` command or the ``enable_on`` condition. Defaults to - ``True``. + Initial enabled state. If ``False``, the controller does not + execute until re-enabled, either via the ``enable`` command or the + ``enable_on`` condition. Defaults to ``True``. disable_on : str or int or float or callable, optional Condition that automatically disables the controller. May be a string preset (``"apogee"`` or ``"burnout"``), a simulation time in seconds (int or float), or a callable ``function(**kwargs)`` that - returns ``True`` when the controller should be disabled. The - condition is forwarded to the wrapped event. Defaults to ``None`` - (no automatic disabling). + returns ``True`` when the controller should be disabled. Defaults + to ``None`` (no automatic disabling). enable_on : str or int or float or callable, optional Condition that automatically re-enables a disabled controller, - using the same formats as ``disable_on``. When the condition is met - while the controller is disabled, it re-enables before the next - trigger evaluation. Defaults to ``None`` (no automatic enabling). - controller_needs : list or frozenset of str or None, optional + using the same formats as ``disable_on``. Defaults to ``None``. + needs : list or frozenset of str or None, optional Declares which expensive simulation values the controller function accesses. Valid keys: ``'state_dot'``, ``'pressure'``, - ``'state_history'``. When ``None`` (default), all values are - computed on every call. Pass an explicit list or frozenset to skip - computing values the controller does not use. + ``'state_history'``. The default ``None`` is treated as an empty + set and no expensive kwargs are computed. See Also -------- - to_event : Builds the :class:`Event` that wraps this controller. :ref:`eventusage` : Description of the callback ``**kwargs``. """ - # TODO: rethink controllers - self.controller_needs = controller_needs - self.controller_function = self.__evaluate_controller_function( + self.controller_function = self.__validate_controller_function( controller_function ) - self.controlled_objects = controlled_objects - # Optional friendly name(s) to expose controlled objects in callback kwargs - # Accept either a single string name or an iterable of string names - self.controlled_objects_name = controlled_objects_name - self._controlled_objects_bindings = self.__verify_controlled_objects_name() - self.sampling_rate = sampling_rate - self.name = name - self.context = context if context is not None else {} + self.bind_controlled_objects(controlled_objects, controlled_objects_name) + super().__init__( + callback=self._controller_callback, + trigger=None, + sampling_rate=sampling_rate, + context=context, + disable_on=disable_on, + enable_on=enable_on, + trigger_only_once=False, + changes_dynamics=True, + name=name, + enabled=enabled, + priority=3, + needs=needs, + ) self.prints = _ControllerPrints(self) - self.enabled = enabled - self.disable_on = disable_on - self.enable_on = enable_on - - # Create the event during initialization - self.event = self.to_event() - self.log = self.event.callback_log - - def __evaluate_controller_function(self, controller_function): - """Detect legacy positional-argument signatures and wrap them for compatibility.""" - sig = signature(controller_function) - params = list(sig.parameters.values()) - positional_count = sum( - p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in params + self.plots = _ControllerPlots(self) + + def __validate_controller_function(self, controller_function): + """Require a keyword-only callable; reject positional signatures.""" + if not callable(controller_function): + raise ValueError("controller_function must be a callable.") + try: + parameters = signature(controller_function).parameters.values() + except (TypeError, ValueError): + # Builtins / callables without an inspectable signature: allow. + return controller_function + has_positional = any( + p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL) + for p in parameters ) - accepts_var_positional = any(p.kind == p.VAR_POSITIONAL for p in params) - - if positional_count > 0 or accepts_var_positional: - warnings.warn( - "It is recommended not to use positional arguments when defining " - "a controller function. Instead, define the controller function " - "to accept `**kwargs` only and read values such as " - "`kwargs['time']`, `kwargs['state']`, `kwargs['sensors']` and " - "`kwargs['environment']`. See the controller documentation for " - "the full list of available keyword arguments.", - UserWarning, - stacklevel=3, + if has_positional: + raise ValueError( + "controller_function must accept keyword arguments only. " + "Define it as `def controller_function(**kwargs):` and read " + "values such as kwargs['time'], kwargs['state'], " + "kwargs['sensors'] and kwargs['environment']. Support for " + "positional controller signatures was removed; see the " + "controller documentation for the full list of available " + "keyword arguments." ) - - def wrapped(**kwargs): - args = [ - kwargs.get("time"), - self.sampling_rate, - kwargs.get("state"), - kwargs.get("state_history"), - self.log, - self.controlled_objects, - ] - if positional_count >= 7 or accepts_var_positional: - args.append(kwargs.get("sensors")) - if positional_count >= 8 or accepts_var_positional: - args.append(kwargs.get("environment")) - return controller_function(*args) - - return wrapped - return controller_function - def to_event(self): - """Create an Event that wraps this controller for simulation execution. + def bind_controlled_objects(self, controlled_objects, controlled_objects_name=None): + """Bind (or re-bind) the objects this controller drives. - Returns - ------- - Event - Event configured for controller sampling rate and callback. - - Notes - ----- - The Event callback directly invokes the controller function with - proper parameters. The controller is responsible for mutating - controlled_objects to apply control actions. + Normalizes and validates ``controlled_objects`` / + ``controlled_objects_name``, rebuilds the friendly-name callback + bindings, and rebuilds the control-state tracking table used by + :attr:`control_history`. Used at construction and when re-wiring a + deserialized controller to freshly loaded objects. + + Parameters + ---------- + controlled_objects : object or list of object + Object(s) the controller is allowed to modify. + controlled_objects_name : str or list of str, optional + Friendly name(s) for the callback kwargs; same semantics as in + the constructor. """ + self.controlled_objects = controlled_objects + self.controlled_objects_name = controlled_objects_name + self._controlled_objects_bindings = self.__verify_controlled_objects_name() - def controller_callback(**kwargs): - """Execute controller and handle mutations. - - Parameters - ---------- - **kwargs : dict - Event context including: - - time: float, simulation time - - state: list, state vector - - state_history: list, state trajectory - - sensors: dict, sensor measurements - - environment: Environment, environmental model - - event: Event, the event object itself - - controller: _Controller, this controller instance - - (other standard Event kwargs) - - Returns - ------- - dict or None - callback_log dict from controller function, logged to callback_log. - """ - # Inject controller reference into kwargs (like kwargs["event"] for events) - kwargs["controller"] = self - kwargs["controlled_objects"] = self.controlled_objects - - # Also expose controlled objects under a user-provided name - if self._controlled_objects_bindings: - kwargs.update(self._controlled_objects_bindings) - - # Call controller function with kwargs directly - # The function can access context via kwargs["controller"].context - # and controlled_objects via kwargs["controlled_objects"] or - # via the provided friendly name. - callback_log = self.controller_function(**kwargs) - return callback_log - - return Event( - callback=controller_callback, - name=f"{self.name}", - sampling_rate=self.sampling_rate, - context=self.context, # Pass context to Event - changes_dynamics=True, - trigger_only_once=False, - enabled=self.enabled, - disable_on=self.disable_on, - enable_on=self.enable_on, - priority=3, - needs=self.controller_needs - if self.controller_needs is not None - else frozenset(), - ) + if isinstance(controlled_objects, (list, tuple)): + objects_list = list(controlled_objects) + else: + objects_list = [controlled_objects] + self._controlled_objects_list = objects_list + + # Friendly names double as tracking names when they map one-to-one. + if isinstance(controlled_objects_name, str) and len(objects_list) == 1: + names = [controlled_objects_name] + elif isinstance(controlled_objects_name, (list, tuple)): + names = list(controlled_objects_name) + else: + names = [None] * len(objects_list) + + tracked = [] + used_names = set() + for i, obj in enumerate(objects_list): + if not hasattr(obj, "control_state"): + continue + base_name = (names[i] if i < len(names) else None) or getattr( + obj, "name", f"object_{i}" + ) + unique_name = base_name + suffix = 2 + while unique_name in used_names: + unique_name = f"{base_name}_{suffix}" + suffix += 1 + used_names.add(unique_name) + tracked.append((unique_name, obj)) + self._tracked = tracked + self.control_history = { + name: {var: [] for var in self.__control_variables(obj)} + for name, obj in tracked + } + + @staticmethod + def __control_variables(obj): + """Ordered control-variable names of a controlled object.""" + return getattr(obj, "control_variables", None) or list(obj.control_state) + + def _controller_callback(self, **kwargs): + """Event callback: run the controller function, then snapshot the + control state of every tracked object at the execution time.""" + kwargs["controller"] = self + kwargs["controlled_objects"] = self.controlled_objects + if self._controlled_objects_bindings: + kwargs.update(self._controlled_objects_bindings) + result = self.controller_function(**kwargs) + self._record_control_state(kwargs.get("time")) + return result + + def _record_control_state(self, time): + """Append the current control state of each tracked object to + :attr:`control_history`, timestamped with ``time``.""" + for name, obj in self._tracked: + history = self.control_history[name] + state = obj.control_state + for variable in history: + history[variable].append((time, state[variable])) @property - def enabled(self): - """Return the current enabled state mirrored from the wrapped event.""" - if hasattr(self, "event"): - return self.event.enabled - return self._enabled - - @enabled.setter - def enabled(self, value): - self._enabled = bool(value) - if hasattr(self, "event"): - self.event.enabled = self._enabled + def recorded_schedule(self): + """Recorded control history as ``Function`` objects of time. - def __str__(self): - return f"Controller '{self.name}' with sampling rate {self.sampling_rate} Hz." + Returns + ------- + dict + ``{object_name: {variable: Function}}`` built from + :attr:`control_history` (linear interpolation, constant + extrapolation). Objects/variables without samples are omitted. + """ + schedule = {} + for object_name, variables in self.control_history.items(): + object_schedule = {} + for variable, samples in variables.items(): + if not samples: + continue + if len(samples) == 1: + source = samples[0][1] + else: + source = np.array(samples) + object_schedule[variable] = Function( + source, + inputs="Time (s)", + outputs=variable, + interpolation="linear", + extrapolation="constant", + ) + if object_schedule: + schedule[object_name] = object_schedule + return schedule + + def reset(self): + """Reset controller runtime state. + + In addition to the :class:`Event` reset (commands, logs, enabled + flag, ``context`` snapshot), clears the recorded + :attr:`control_history` and restores every tracked controlled object + to its initial control state. Called by ``Flight`` at the start of + each simulation. + """ + super().reset() + for variables in self.control_history.values(): + for variable in variables: + variables[variable] = [] + for _, obj in self._tracked: + if hasattr(obj, "_reset"): + obj._reset() @property def log(self): - """Return the controller callback log.""" - return self._log + """Per-execution return values of ``controller_function`` (same list + as ``callback_log``).""" + return self.callback_log @log.setter def log(self, value): - self._log = value - if hasattr(self, "event"): - self.event.callback_log = value + self.callback_log = value @property def return_log(self): @@ -307,6 +339,9 @@ def return_log(self): def return_log(self, value): self.log = value + def __str__(self): + return f"Controller '{self.name}' with sampling rate {self.sampling_rate} Hz." + def __verify_controlled_objects_name(self): """Validate controlled_objects_name and build callback bindings.""" if self.controlled_objects_name is None: @@ -382,8 +417,9 @@ def info(self): self.prints.all() def all_info(self): - """Prints out all information about the controller.""" + """Prints and plots all information about the controller.""" self.info() + self.plots.all() def to_dict(self, **kwargs): """Serialize controller to dictionary. @@ -395,6 +431,10 @@ def to_dict(self, **kwargs): If True, serialize controller_function, disable_on, and enable_on callables using hex encoding. If False, use function name. Default is True. + include_outputs : bool, optional + If True, include the recorded ``control_history`` so a loaded + controller can expose its schedule (and be replayed) without + re-simulating. Default is False. Returns ------- @@ -417,18 +457,34 @@ def to_dict(self, **kwargs): if allow_pickle and callable(enable_on): enable_on = to_hex_encode(enable_on) - return { + data = { "controller_function": controller_function, "sampling_rate": self.sampling_rate, "name": self.name, - "controlled_objects_name": getattr(self, "controlled_objects_name", None), + "controlled_objects_name": self.controlled_objects_name, "context": self.context.copy(), # Preserve context state "enabled": self.enabled, "disable_on": disable_on, "enable_on": enable_on, - # Note: controlled_objects are recovered in from_dict via - # object reference matching in Rocket deserialization + "needs": sorted(self.needs), + # Controlled objects are not serialized here; their object names + # are recorded so Rocket deserialization can rewire them to the + # freshly decoded surfaces by name. + "controlled_objects_ref": [ + getattr(obj, "name", tracked_name) + for tracked_name, obj in self._tracked + ] + or getattr(self, "_controlled_objects_ref", []), } + if kwargs.get("include_outputs", False): + data["control_history"] = { + object_name: { + variable: [list(sample) for sample in samples] + for variable, samples in variables.items() + } + for object_name, variables in self.control_history.items() + } + return data @classmethod def from_dict(cls, data, controlled_objects=None): @@ -439,12 +495,12 @@ def from_dict(cls, data, controlled_objects=None): data : dict Serialized controller data from to_dict(). controlled_objects : list or object, optional - Objects the controller will mutate. If not provided, - must be set manually after reconstruction. + Objects the controller will mutate. If not provided, must be + re-bound after reconstruction via ``bind_controlled_objects``. Returns ------- - _Controller + Controller Reconstructed controller instance. """ controller_function = data.get("controller_function") @@ -455,11 +511,32 @@ def from_dict(cls, data, controlled_objects=None): enabled = data.get("enabled", True) disable_on = data.get("disable_on") enable_on = data.get("enable_on") + needs = data.get("needs") or None + control_history = data.get("control_history") try: controller_function = from_hex_decode(controller_function) except (TypeError, ValueError): pass + if not callable(controller_function): + # The controller function could not be restored (e.g. it was + # serialized by name only, or unpickling failed across + # environments). If a recorded control history is available, fall + # back to replaying it open-loop. + if control_history: + warnings.warn( + f"The controller function of '{name}' could not be " + "restored; building a ScheduledController that replays " + "the recorded control schedule open-loop instead.", + UserWarning, + ) + return cls._scheduled_from_history(data, controlled_objects) + raise ValueError( + f"Could not restore the controller function of '{name}', and " + "no recorded control history is available to replay. " + "Re-create the controller manually from its original " + "function." + ) # Deserialize disable_on: try hex decoding for callables, keep strings and None try: @@ -473,17 +550,89 @@ def from_dict(cls, data, controlled_objects=None): except (TypeError, ValueError): pass + pending_name = None if controlled_objects is None: + # No objects to bind yet: construct unbound and keep the friendly + # name around so a later ``bind_controlled_objects`` (e.g. during + # Rocket deserialization) can restore it. controlled_objects = [] + pending_name = controlled_objects_name + controlled_objects_name = None - return cls( - controller_function=controller_function, - controlled_objects=controlled_objects, - sampling_rate=sampling_rate, + controller = cls._construct( + controller_function, + controlled_objects, + sampling_rate, name=name, context=context, controlled_objects_name=controlled_objects_name, enabled=enabled, disable_on=disable_on, enable_on=enable_on, + needs=needs, + ) + if pending_name is not None: + controller.controlled_objects_name = pending_name + cls._restore_serialized_state(controller, data) + return controller + + @classmethod + def _construct( + cls, controller_function, controlled_objects, sampling_rate, **kwargs + ): + """Constructor hook for ``from_dict``; subclasses whose ``__init__`` + renames or drops parameters override this.""" + return cls(controller_function, controlled_objects, sampling_rate, **kwargs) + + @staticmethod + def _restore_serialized_state(controller, data): + """Restore serialization-only attributes: the controlled-object name + references used for rewiring, and any recorded control history (so + ``recorded_schedule`` works on a loaded controller).""" + controller._controlled_objects_ref = data.get("controlled_objects_ref", []) + control_history = data.get("control_history") + if control_history: + controller.control_history = { + object_name: { + variable: [tuple(sample) for sample in samples] + for variable, samples in variables.items() + } + for object_name, variables in control_history.items() + } + + @classmethod + def _scheduled_from_history(cls, data, controlled_objects): + """Build a ScheduledController replaying the recorded control history + of a serialized controller whose function could not be restored.""" + # Resolved dynamically to avoid a circular import between this module + # and scheduled_controller (which subclasses Controller). + scheduled_controller_class = import_module( + "rocketpy.control.scheduled_controller" + ).ScheduledController + + schedule = { + object_name: { + variable: np.array(samples) + for variable, samples in variables.items() + if samples + } + for object_name, variables in data["control_history"].items() + } + schedule = { + object_name: variables + for object_name, variables in schedule.items() + if variables + } + controller = scheduled_controller_class( + schedule=schedule, + controlled_objects=( + controlled_objects if controlled_objects is not None else [] + ), + sampling_rate=data.get("sampling_rate"), + context=data.get("context", {}), + name=data.get("name", "Controller"), + enabled=data.get("enabled", True), ) + controller.controlled_objects_name = data.get("controlled_objects_name") + Controller._restore_serialized_state(controller, data) + return controller diff --git a/rocketpy/control/scheduled_controller.py b/rocketpy/control/scheduled_controller.py new file mode 100644 index 000000000..7a293c35a --- /dev/null +++ b/rocketpy/control/scheduled_controller.py @@ -0,0 +1,187 @@ +from rocketpy.mathutils.function import Function + +from .controller import Controller + + +class ScheduledController(Controller): + """A :class:`Controller` that replays a pre-defined control schedule + open-loop. + + At every execution the scheduled value of each control variable is looked + up at the current simulation time and applied to the controlled objects + through ``set_control`` (so clamping still applies). Its main use is + reconstructing/replaying a recorded flight: pass a controller's + ``recorded_schedule`` (or build one with :meth:`from_controller`) and the + flight is re-flown with the same control inputs without the original + controller function. + + Attributes + ---------- + ScheduledController.schedule : dict + ``{object_name: {variable: Function}}`` giving each control variable + as a function of time. + """ + + def __init__( + self, + schedule, + controlled_objects, + sampling_rate, + context=None, + name="Scheduled Controller", + controlled_objects_name=None, + enabled=True, + disable_on=None, + enable_on=None, + ): + """Initialize the scheduled controller. + + Parameters + ---------- + schedule : dict + Control values over time. Either nested, + ``{object_name: {variable: source}}`` (object names as tracked by + the controller, see ``Controller.control_history``), or flat, + ``{variable: source}``, when there is a single controlled object. + Each source may be a :class:`Function` of time, a callable, a + constant, or a list/array of ``(time, value)`` points + (interpolated linearly, with constant extrapolation). + controlled_objects : object or list of object + Object(s) the schedule is applied to; see :class:`Controller`. + sampling_rate : float + Rate in hertz at which the schedule is applied. + context, name, controlled_objects_name, enabled, disable_on, + enable_on : + Same as in :class:`Controller`. + """ + super().__init__( + self._apply_schedule, + controlled_objects, + sampling_rate, + context=context, + name=name, + controlled_objects_name=controlled_objects_name, + enabled=enabled, + disable_on=disable_on, + enable_on=enable_on, + ) + self.schedule = self._normalize_schedule(schedule) + + def _normalize_schedule(self, schedule): + """Normalize the schedule to ``{object_name: {variable: Function}}``, + resolving the flat single-object form against the tracked objects.""" + if not isinstance(schedule, dict): + raise TypeError( + "schedule must be a dict of {object_name: {variable: source}} " + "or {variable: source} for a single controlled object." + ) + is_nested = all(isinstance(value, dict) for value in schedule.values()) + if not is_nested: + if len(self._tracked) != 1: + raise ValueError( + "A flat {variable: source} schedule requires exactly one " + "controlled object with a control state; got " + f"{len(self._tracked)}. Use the nested " + "{object_name: {variable: source}} form instead." + ) + schedule = {self._tracked[0][0]: schedule} + return { + object_name: { + variable: self._as_function(source, variable) + for variable, source in variables.items() + } + for object_name, variables in schedule.items() + } + + @staticmethod + def _as_function(source, variable): + if isinstance(source, Function): + return source + return Function( + source, + inputs="Time (s)", + outputs=variable, + interpolation="linear", + extrapolation="constant", + ) + + def _apply_schedule(self, **kwargs): + """Apply the scheduled control values at the current time.""" + time = kwargs["time"] + for object_name, obj in self._tracked: + for variable, function in self.schedule.get(object_name, {}).items(): + obj.set_control(variable, function.get_value_opt(time)) + + @classmethod + def from_controller( + cls, controller, controlled_objects=None, sampling_rate=None, name=None + ): + """Build an open-loop replay of another controller's recorded + schedule. + + Parameters + ---------- + controller : Controller + Controller whose ``recorded_schedule`` (from a previous flight) + is replayed. + controlled_objects : object or list, optional + Objects to drive; defaults to the source controller's. + sampling_rate : float, optional + Defaults to the source controller's sampling rate. + name : str, optional + Defaults to ``" (replay)"``. + + Returns + ------- + ScheduledController + """ + schedule = controller.recorded_schedule + if not schedule: + raise ValueError( + f"Controller '{controller.name}' has no recorded control " + "history to replay - run a Flight with it first." + ) + return cls( + schedule=schedule, + controlled_objects=( + controlled_objects + if controlled_objects is not None + else controller.controlled_objects + ), + sampling_rate=( + sampling_rate if sampling_rate is not None else controller.sampling_rate + ), + controlled_objects_name=controller.controlled_objects_name, + name=name or f"{controller.name} (replay)", + ) + + def to_dict(self, **kwargs): + """Serialize the scheduled controller; the schedule itself is stored + (as Functions) instead of a pickled controller function.""" + data = super().to_dict(**kwargs) + data["controller_function"] = None + data["schedule"] = self.schedule + return data + + @classmethod + def from_dict(cls, data, controlled_objects=None): + pending_name = None + controlled_objects_name = data.get("controlled_objects_name") + if controlled_objects is None: + controlled_objects = [] + pending_name = controlled_objects_name + controlled_objects_name = None + + controller = cls( + schedule=data["schedule"], + controlled_objects=controlled_objects, + sampling_rate=data.get("sampling_rate"), + context=data.get("context", {}), + name=data.get("name", "Scheduled Controller"), + controlled_objects_name=controlled_objects_name, + enabled=data.get("enabled", True), + ) + if pending_name is not None: + controller.controlled_objects_name = pending_name + controller._controlled_objects_ref = data.get("controlled_objects_ref", []) + return controller diff --git a/rocketpy/control/surface_controller.py b/rocketpy/control/surface_controller.py new file mode 100644 index 000000000..8359fd930 --- /dev/null +++ b/rocketpy/control/surface_controller.py @@ -0,0 +1,60 @@ +from .controller import Controller + + +class SurfaceController(Controller): + """A :class:`Controller` that drives one or more + :class:`rocketpy.ControllableGenericSurface` objects. + + The controller function applies control actions through the surfaces' + ``set_control`` (or convenience attributes such as + ``AirBrakes.deployment_level``); the base class records each surface's + control state after every execution and restores it between flights. + + See Also + -------- + Controller : Base class holding the tracking/reset machinery. + AirBrakesController : Specialization for air brakes. + """ + + def __init__(self, controller_function, surfaces, sampling_rate, **kwargs): + """Initialize the surface controller. + + Parameters + ---------- + controller_function : callable + Control logic with signature ``controller_function(**kwargs)``; + see :class:`Controller` for the available keyword arguments. + surfaces : ControllableGenericSurface or list + Controllable surface(s) this controller drives. + sampling_rate : float + Rate in hertz at which the controller executes. + **kwargs : dict + Remaining :class:`Controller` options (``context``, ``name``, + ``controlled_objects_name``, ``enabled``, ``disable_on``, + ``enable_on``, ``needs``). + """ + self._validate_surfaces(surfaces) + super().__init__(controller_function, surfaces, sampling_rate, **kwargs) + + @staticmethod + def _validate_surfaces(surfaces): + """Require every controlled object to be a ControllableGenericSurface.""" + # Imported here to avoid a circular import with the rocket package. + from rocketpy.rocket.aero_surface.controllable_generic_surface import ( # pylint: disable=import-outside-toplevel + ControllableGenericSurface, + ) + + candidates = surfaces if isinstance(surfaces, (list, tuple)) else [surfaces] + for surface in candidates: + if not isinstance(surface, ControllableGenericSurface): + raise TypeError( + f"SurfaceController can only control ControllableGenericSurface " + f"objects, but got {type(surface).__name__} " + f"('{getattr(surface, 'name', surface)}'). Use the base " + "Controller class for other controlled objects." + ) + + @property + def surfaces(self): + """List of the controlled surfaces.""" + return self._controlled_objects_list diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index e9b30da45..a3753d660 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -1,6 +1,4 @@ # pylint: disable=too-many-statements -from abc import ABC, abstractmethod - import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Ellipse @@ -8,16 +6,16 @@ from .plot_helpers import show_or_save_plot -class _AeroSurfacePlots(ABC): - """Abstract class that contains all aero surface plots.""" +class _GenericSurfacePlots: + """Base plots for a generic aerodynamic surface.""" def __init__(self, aero_surface): """Initialize the class Parameters ---------- - aero_surface : rocketpy.AeroSurface - AeroSurface object to be plotted + aero_surface : rocketpy.GenericSurface + Aerodynamic surface object to be plotted Returns ------- @@ -25,9 +23,95 @@ def __init__(self, aero_surface): """ self.aero_surface = aero_surface - @abstractmethod def draw(self, *, filename=None): - pass + """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. + + Parameters + ---------- + mach : float, optional + Mach number at which to sample the coefficients. Default 0.3. + angle_range_deg : float, optional + Half-range of the incidence sweep, in degrees. Default 15. + filename : str | None, optional + Path to save the figure; if None the figure is shown. + """ + surface = self.aero_surface + independent_vars = getattr(surface, "independent_vars", None) + if independent_vars is None: + return + index = {name: i for i, name in enumerate(independent_vars)} + if "mach" not in index: + return + n_args = len(independent_vars) + + angles = np.linspace( + np.deg2rad(-angle_range_deg), np.deg2rad(angle_range_deg), 61 + ) + entries = [] + for name, var in self._COEFFICIENT_SWEEP: + coeff = getattr(surface, name, None) + if coeff is None or getattr(coeff, "is_zero", False): + continue + if var not in index: + continue + values = np.empty_like(angles) + for i, angle in enumerate(angles): + args = [0.0] * n_args + args[index["mach"]] = mach + args[index[var]] = angle + values[i] = coeff(*args) + if np.allclose(values, 0.0): + continue + entries.append((name, var, values)) + + if not entries: + return + + fig, axes = plt.subplots( + len(entries), 1, figsize=(7, 2.3 * len(entries)), squeeze=False + ) + for ax, (name, var, values) in zip(axes[:, 0], entries): + ax.plot(np.rad2deg(angles), values) + ax.set_xlabel(f"{var.replace('_', ' ').title()} (°)") + ax.set_ylabel(name) + ax.grid(True) + axes[0, 0].set_title(f"{surface.name} coefficients (Mach {mach})") + plt.tight_layout() + show_or_save_plot(filename) + + def all(self): + """Plots the generic surface's aerodynamic coefficients.""" + self.coefficients() + + +class _LinearGenericSurfacePlots(_GenericSurfacePlots): + """Plots for a linear generic surface; same plots as the generic base.""" + + +class _BarrowmanSurfacePlots(_LinearGenericSurfacePlots): + """Plots shared by the geometry-defined (Barrowman) surfaces: adds the + geometry drawing and the lift-coefficient surface plot.""" def lift(self): """Plots the lift coefficient of the aero surface as a function of Mach @@ -41,19 +125,16 @@ class for more information on how this plot is made. self.aero_surface.cl() def all(self): - """Plots all aero surface plots. - - Returns - ------- - None - """ + """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 +217,9 @@ def draw(self, *, filename=None): show_or_save_plot(filename) -class _FinsPlots(_AeroSurfacePlots): +class _FinsPlots(_BarrowmanSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def airfoil(self, *, filename=None): """Plots the airfoil information when the fin has an airfoil shape. If @@ -215,11 +296,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 @@ -295,6 +377,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 +924,7 @@ def draw(self, *, filename=None): show_or_save_plot(filename) -class _TailPlots(_AeroSurfacePlots): +class _TailPlots(_BarrowmanSurfacePlots): """Class that contains all tail plots.""" def draw(self, *, filename=None): @@ -849,7 +932,7 @@ def draw(self, *, filename=None): pass -class _AirBrakesPlots(_AeroSurfacePlots): +class _AirBrakesPlots(_GenericSurfacePlots): """Class that contains all air brakes plots.""" def drag_coefficient_curve(self): @@ -870,17 +953,3 @@ def all(self): None """ self.drag_coefficient_curve() - - -class _GenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all generic surface plots.""" - - def draw(self, *, filename=None): - pass - - -class _LinearGenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all linear generic surface plots.""" - - def draw(self, *, filename=None): - pass diff --git a/rocketpy/plots/controller_plots.py b/rocketpy/plots/controller_plots.py new file mode 100644 index 000000000..01c4c7897 --- /dev/null +++ b/rocketpy/plots/controller_plots.py @@ -0,0 +1,122 @@ +import matplotlib.pyplot as plt + +from .plot_helpers import show_or_save_plot + + +class _ControllerPlots: + """Class that holds plot methods for Controller class. + + Attributes + ---------- + _ControllerPlots.controller : Controller + Controller object that will be used for the plots. + """ + + def __init__(self, controller): + """Initializes _ControllerPlots class. + + Parameters + ---------- + controller : Controller + Instance of the Controller class. + + Returns + ------- + None + """ + self.controller = controller + + def control_history(self, *, filename=None): + """Plots the recorded control state of every controlled object as a + function of time, one axes per (object, variable) pair. + + Parameters + ---------- + filename : str or None, optional + Path to save the figure. If None, the figure is shown instead. + + Returns + ------- + None + """ + schedule = self.controller.recorded_schedule + pairs = [ + (object_name, variable, function) + for object_name, variables in schedule.items() + for variable, function in variables.items() + ] + if not pairs: + print( + "No recorded control history - run a Flight with this controller first." + ) + return + + fig, axes = plt.subplots( + len(pairs), 1, figsize=(7, 3 * len(pairs)), sharex=True, squeeze=False + ) + for ax, (object_name, variable, function) in zip(axes[:, 0], pairs): + samples = self.controller.control_history[object_name][variable] + times = [sample[0] for sample in samples] + values = [sample[1] for sample in samples] + ax.plot(times, values) + ax.set_ylabel(variable) + ax.set_title(f"{object_name}: {variable}") + ax.grid(True) + axes[-1, 0].set_xlabel("Time (s)") + fig.suptitle(f"Control history - {self.controller.name}") + fig.tight_layout() + show_or_save_plot(filename) + + def all(self): + """Plots all available controller plots. + + Returns + ------- + None + """ + self.control_history() + + +class _AirBrakesControllerPlots(_ControllerPlots): + """Plots for AirBrakesController.""" + + def deployment_level_curve(self, *, filename=None): + """Plots the recorded deployment level as a function of time. + + Parameters + ---------- + filename : str or None, optional + Path to save the figure. If None, the figure is shown instead. + + Returns + ------- + None + """ + samples = self.controller.control_history.get("air_brakes", {}).get( + "deployment_level", [] + ) + if not samples: + print( + "No recorded deployment level history - run a Flight with " + "this controller first." + ) + return + times = [sample[0] for sample in samples] + values = [sample[1] for sample in samples] + fig, ax = plt.subplots(figsize=(7, 4)) + ax.plot(times, values) + ax.set_xlabel("Time (s)") + ax.set_ylabel("Deployment Level") + ax.set_title(f"Deployment level - {self.controller.name}") + ax.grid(True) + fig.tight_layout() + show_or_save_plot(filename) + + def all(self): + """Plots all available air brakes controller plots. + + Returns + ------- + None + """ + self.deployment_level_curve() 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..02869ece6 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["cQ"], coeffs["cn"] + else: + coeffs = rocket.aerodynamic_coefficients_full(angle, 0.0, 0.0) + force, moment = coeffs["cL"], coeffs["cm"] + if force == 0: + continue + position = cdm + csys * diameter * moment / force + if np.isfinite(position): + positions.append(position) + if len(positions) == 0: + return (0.0, 0.0) + return (float(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/controller_prints.py b/rocketpy/prints/controller_prints.py index 1d5c67d83..7509b5441 100644 --- a/rocketpy/prints/controller_prints.py +++ b/rocketpy/prints/controller_prints.py @@ -46,19 +46,39 @@ def controller_function(self): else: print(f"Controller refresh rate: {self.controller.sampling_rate:.3f} Hz") - def interactive_objects(self): - """Prints interactive objects.""" - print("interactive Objects") - # check if is list - if isinstance(self.controller.interactive_objects, list): - for obj in self.controller.interactive_objects: - print(getattr(obj, "name", str(obj))) - else: - obj = self.controller.interactive_objects + def controlled_objects(self): + """Prints the objects controlled by the controller.""" + print("Controlled Objects") + controlled_objects = self.controller.controlled_objects + if not isinstance(controlled_objects, (list, tuple)): + controlled_objects = [controlled_objects] + for obj in controlled_objects: print(getattr(obj, "name", str(obj))) + def control_history(self): + """Prints a summary of the recorded control history, per controlled + object and control variable.""" + history = getattr(self.controller, "control_history", {}) + if not any( + samples for variables in history.values() for samples in variables.values() + ): + print("No recorded control history.") + return + print("Recorded Control History") + for object_name, variables in history.items(): + for variable, samples in variables.items(): + if not samples: + continue + times = [sample[0] for sample in samples] + values = [sample[1] for sample in samples] + print( + f"'{object_name}.{variable}': {len(samples)} samples from " + f"t = {times[0]:.3f} s to t = {times[-1]:.3f} s, " + f"range [{min(values):.4g}, {max(values):.4g}]" + ) + def all(self): - """Prints all information about the parachute. + """Prints all information about the controller. Returns ------- @@ -67,5 +87,30 @@ def all(self): print("\nController Details\n") print(self.controller) + print(f"Enabled: {self.controller.enabled}") + if self.controller.needs: + print(f"Declared needs: {sorted(self.controller.needs)}") self.controller_function() - self.interactive_objects() + self.controlled_objects() + self.control_history() + + +class _AirBrakesControllerPrints(_ControllerPrints): + """Prints for AirBrakesController, adding a deployment-level summary.""" + + def deployment_level(self): + """Prints a summary of the recorded deployment level.""" + samples = self.controller.control_history.get("air_brakes", {}).get( + "deployment_level", [] + ) + if not samples: + print("No recorded deployment level history.") + return + values = [sample[1] for sample in samples] + print(f"Final deployment level: {values[-1]:.4g}") + print(f"Maximum deployment level: {max(values):.4g}") + + def all(self): + """Prints all information about the air brakes controller.""" + super().all() + self.deployment_level() diff --git a/rocketpy/prints/flight_prints.py b/rocketpy/prints/flight_prints.py index e28ed2a12..51eb40a26 100644 --- a/rocketpy/prints/flight_prints.py +++ b/rocketpy/prints/flight_prints.py @@ -408,23 +408,22 @@ def controller_events(self): return for controller in self.flight._controllers: - event = controller.event - event_name = event.name if event.name else "Unnamed Controller Event" + # A Controller is an Event, so event data lives on the controller. + event_name = controller.name if controller.name else "Unnamed Controller" print(f"Controller Event: {event_name}") - print(f"\tParent Controller: {controller.name}") - if event.sampling_rate is None: + if controller.sampling_rate is None: print("\tSampling: Continuous") else: - print(f"\tSampling Rate: {event.sampling_rate:.3f} Hz") + print(f"\tSampling Rate: {controller.sampling_rate:.3f} Hz") - print(f"\tTriggered Count: {len(event.triggered_times)}") - if len(event.triggered_times) > 0: - print(f"\tFirst Activation Time: {event.triggered_times[0]:.3f} s") - print(f"\tLast Activation Time: {event.triggered_times[-1]:.3f} s") + print(f"\tTriggered Count: {len(controller.triggered_times)}") + if len(controller.triggered_times) > 0: + print(f"\tFirst Activation Time: {controller.triggered_times[0]:.3f} s") + print(f"\tLast Activation Time: {controller.triggered_times[-1]:.3f} s") - print(f"\tCallback Log Entries: {len(event.callback_log)}") + print(f"\tCallback Log Entries: {len(controller.callback_log)}") print() def impact_conditions(self): @@ -631,6 +630,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..4c62a3d6d 100644 --- a/rocketpy/rocket/__init__.py +++ b/rocketpy/rocket/__init__.py @@ -1,7 +1,8 @@ -from rocketpy.control.controller import _Controller +from rocketpy.control.controller import Controller from rocketpy.rocket.aero_surface import ( AeroSurface, AirBrakes, + ControllableGenericSurface, EllipticalFin, EllipticalFins, Fin, @@ -17,6 +18,7 @@ TrapezoidalFins, ) from rocketpy.rocket.components import Components +from rocketpy.rocket.effector import Effector, GenericEffector from rocketpy.rocket.parachute import Parachute from rocketpy.rocket.point_mass_rocket import PointMassRocket from rocketpy.rocket.rocket import Rocket 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..3ae96024b --- /dev/null +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -0,0 +1,126 @@ +import numpy as np + +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient +from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface + + +class _BarrowmanSurface(LinearGenericSurface): + """Intermediate base for geometry-defined (Barrowman) aerodynamic surfaces + such as nose cones, tails/transitions and fin sets. + + These surfaces historically expose a lift-curve slope ``clalpha`` (a + ``Function`` of Mach), a geometric center of pressure ``cpz`` and, for fins, + a pair of roll forcing/damping coefficients. This class translates that + Barrowman description into the linear generic-surface coefficient model so + the forces and moments are computed by the single, shared + :meth:`GenericSurface.compute_forces_and_moments`: + + - normal-force slope -> ``cL_alpha`` (pitch plane) and ``cQ_beta`` (yaw plane); + - center-of-pressure offset -> ``cm_alpha`` / ``cn_beta`` (the moment is + carried by the coefficients, with the force applied at the surface origin); + - fin roll -> ``cl_0`` (cant forcing) and ``cl_p`` (roll damping). + + Subclasses must compute ``self.clalpha`` (Function of Mach) and the geometric + center of pressure before calling ``super().__init__`` (which passes the + geometric cp through ``center_of_pressure``), and, for fins, set + ``self.roll_parameters = [clf_delta, cld_omega, cant_angle_rad]``. + """ + + # Geometry-defined Barrowman surfaces are axisymmetric by construction + # (``cQ_beta = -cL_alpha``, etc.), so they contribute identically to the + # pitch and yaw planes. The individual ``Fin`` overrides this back to False. + is_axisymmetric = True + + @staticmethod + def _beta(mach): + """Prandtl-Glauert compressibility factor used to correct subsonic + force coefficients of the nose cone, fins and tails/transitions, as in + Barrowman. + + Parameters + ---------- + mach : int, float + Mach number. + + Returns + ------- + beta : float + Compressibility factor based on the Mach number. + + References + ---------- + [1] Barrowman, James S. https://arc.aiaa.org/doi/10.2514/6.1979-504 + """ + if mach < 0.8: + return np.sqrt(1 - mach**2) + elif mach < 1.1: + return np.sqrt(1 - 0.8**2) + else: + return np.sqrt(mach**2 - 1) + + @property + def force_application_point(self): + """Barrowman surfaces apply the resultant force at the surface origin; + the whole center-of-pressure offset is carried by the ``cm``/``cn`` + moment coefficients (avoiding a double count with the ``cp ^ force`` + transport). The geometric center of pressure remains available through + ``self.cp``/``self.cpz`` for display and through + ``center_of_pressure_z`` as a mach-dependent diagnostic. + """ + return Vector([0, 0, 0]) + + def evaluate_coefficients(self): + """Populate the linear generic-surface coefficient derivatives from the + surface geometry. Called by ``GenericSurface.__init__`` and again + whenever the geometry changes. + """ + clalpha = self.clalpha # Function of Mach + cpz = self.cpz # geometric center of pressure (set from center_of_pressure) + reference_length = self.reference_length + + # Axisymmetric Barrowman lift: equal-magnitude slopes in the pitch and + # yaw planes. The yaw-plane (side-force) slope is opposite in sign due to + # the aerodynamic-to-body frame convention used by the shared compute. + self.cL_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach), "cL_alpha" + ) + self.cQ_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach), "cQ_beta" + ) + + # Center-of-pressure offset expressed as moment coefficients (the local + # cp ^ force couple, with the force applied at the origin). + self.cm_alpha = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach) * cpz / reference_length, + "cm_alpha", + ) + self.cn_beta = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach) * cpz / reference_length, + "cn_beta", + ) + + # Fin roll forcing (cant) and damping, when present. + roll_parameters = getattr(self, "roll_parameters", None) + if roll_parameters is not None: + clf_delta, cld_omega, cant_angle_rad = roll_parameters + self.cl_0 = self._mach_coefficient( + lambda mach: clf_delta.get_value_opt(mach) * cant_angle_rad, "cl_0" + ) + self.cl_p = self._mach_coefficient( + lambda mach: cld_omega.get_value_opt(mach), "cl_p" + ) + + def _mach_coefficient(self, func_of_mach, name="coefficient"): + """Wrap a Mach-only callable into an :class:`AeroCoefficient` that + depends only on Mach but is callable over the full coefficient argument + tuple. Storing it at one dimension keeps the Mach table un-smeared and + evaluates with a single argument in the hot loop. + """ + return AeroCoefficient( + func_of_mach, + depends_on=("mach",), + 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..fa0b35012 --- /dev/null +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -0,0 +1,540 @@ +"""Minimal-dimension aerodynamic coefficient storage. + +A :class:`AeroCoefficient` stores a single aerodynamic coefficient at its +*intrinsic* dimensionality - a constant, or a :class:`Function` over only the +variables the coefficient actually depends on (its ``depends_on``) - and maps +the full coefficient argument tuple (in ``independent_vars`` order) down to that +subset on every call. + +This avoids forcing a Mach-only (or constant) coefficient into a full seven +dimensional :class:`Function`: interpolation happens at the right dimension (so +a Mach-only table is not smeared across a 7-D domain) and evaluation passes only +the arguments that matter. It generalizes the per-call ``dict(zip(...))`` subset +selection that the CSV loader used to do inline. +""" + +import copy +import csv +import inspect + +from rocketpy.mathutils import Function + +# Single source of truth for the seven base coefficient independent variables. +BASE_INDEPENDENT_VARS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", +] + + +def build_independent_vars(unsteady_aero=False, control_variables=()): + """Build the ordered independent-variable list of a coefficient/surface. + + The seven base axes (``BASE_INDEPENDENT_VARS``), plus ``alpha_dot`` and + ``beta_dot`` when ``unsteady_aero`` is enabled (axes the flight integrator + supplies automatically), plus any ``control_variables`` (axes supplied + externally, e.g. by a controller). Shared by :class:`AeroCoefficient` and + :class:`GenericSurface` so the ordering is defined in exactly one place. + """ + names = list(BASE_INDEPENDENT_VARS) + if unsteady_aero: + names += ["alpha_dot", "beta_dot"] + names += list(control_variables) + return names + + +class AeroCoefficient: + """A single aerodynamic coefficient stored at minimal dimensionality. + + Building goes through :meth:`__init__`: pass a raw coefficient input + (number, callable, :class:`Function`, list/tuple of points, CSV path, or + another :class:`AeroCoefficient`) and ``depends_on`` is inferred; pass + ``depends_on`` explicitly only on the fast path where it is already known. + """ + + def __init__( + self, + source, + depends_on=None, + unsteady_aero=False, + control_variables=(), + name="coefficient", + extrapolation=None, + single_var=None, + ): + """Build a coefficient stored at minimal dimensionality. + + A number is kept as a plain constant. Anything else is wrapped in a + :class:`Function` over only the variables it depends on (``depends_on``), + so a Mach-only curve stays 1-D instead of being stretched across all + seven axes. On each call the full argument tuple is mapped down to just + those arguments (using the precomputed ``_indices``). The full, ordered + list of variables comes from ``unsteady_aero`` and ``control_variables`` + via :func:`build_independent_vars`. + + Usually you do not pass ``depends_on``: leave it as ``None`` and it is + worked out from ``source`` (a number, a callable, a :class:`Function`, a + list of points, a CSV path, or another :class:`AeroCoefficient`), the + same inputs :class:`GenericSurface` accepts (see :meth:`_resolve_input`). + Pass ``depends_on`` yourself only on the fast path, where the source and + its argument order are already known (the Barrowman surfaces and + serialization). + + Parameters + ---------- + source : number, str, list, tuple, callable, Function, or AeroCoefficient + The coefficient value, or an input it can be worked out from when + ``depends_on`` is ``None``. The accepted forms are: + + - **number**: kept as a constant. Calls return it directly, and + ``is_zero`` is set when it is exactly ``0.0`` (the linear model + uses that to skip the term). It depends on nothing. + - **callable** (function or ``lambda``): wrapped in a + :class:`Function`. When ``depends_on`` is worked out, the + parameter *names* decide it: name them after the variables they + use (e.g. ``lambda alpha, mach: ...``), give one argument per + variable, or use one argument together with ``single_var``. + - **Function**: used as given. If ``extrapolation`` is set, it is + applied to a copy, never to the object you passed in (it may be + shared elsewhere). + - **list/tuple of points**: turned into a :class:`Function` with + linear interpolation, so a list and the same data in a CSV give + the same result. + - **str**: a path to a data file. A ``.csv`` file is read by the CSV + loader (column headers name the variables; a headerless + two-column file is a 1-D table over ``single_var``); other files + are read by :class:`Function`. + - **AeroCoefficient**: an existing coefficient, re-keyed to this + surface's variables. This is what lets a surface round-trip + through ``to_dict``/``from_dict`` and lets one coefficient be + reused on several surfaces. + depends_on : sequence of str, optional + The variables this coefficient actually uses, a (possibly empty) + subset of the surface's full variable list (set by ``unsteady_aero`` + and ``control_variables``). Keep them in the same order as the + source's own arguments (a callable's parameters, a CSV's columns): + that order is used to pick the right values out of the full argument + tuple on each call. For example, ``()`` for a constant, ``("mach",)`` + for a Mach-only curve, or the whole list for something that uses + every variable. A name that is not one of the surface's variables + raises a ``ValueError``. Leave it as ``None`` (the default) to have + it worked out from ``source``; pass it only on the fast path, where + the source and its argument order are already known. + unsteady_aero : bool, optional + Add the unsteady axes to this coefficient's variables. When ``True``, + ``alpha_dot`` and ``beta_dot`` (the rates of change of the angle of + attack and sideslip) are added after the seven base axes, so calls + take two more arguments. The flight integrator fills these in, using + ``0`` when it does not compute them, so ordinary tables keep working. + Match the owning surface's setting. Default ``False``. + control_variables : sequence of str, optional + Names of extra axes supplied from outside, such as control-surface + deflections from a controller. They are added after the base and + unsteady axes, and each one becomes an extra call argument, in the + order given. Used by :class:`ControllableGenericSurface` and air + brakes; empty for ordinary surfaces. Default ``()``. + name : str, optional + A readable name for the coefficient (e.g. ``"cL_alpha"`` or + ``"Drag Coefficient with Power Off"``). It labels the underlying + :class:`Function` and appears in error messages, so a clear name + makes problems easier to spot. Default ``"coefficient"``. + extrapolation : str, optional + How the stored :class:`Function` behaves outside its data range, one + of the options of :meth:`Function.set_extrapolation`: ``"constant"`` + holds the edge value (used for drag, which should not run past its + data), ``"natural"`` keeps following the curve, ``"zero"`` returns + ``0``. ``None`` (the default) leaves a :class:`Function` you passed + in unchanged, and uses ``"natural"`` for one built from a callable. + An override is always applied to a copy, so your object is never + changed. + single_var : str, optional + Which variable a 1-D input maps to. Used only while working out + ``depends_on`` for a single-dimension source: a headerless + two-column CSV, a 1-D :class:`Function`, or a one-argument callable. + ``None`` (the default) guesses it from the input's label, falling + back to the first variable; drag passes ``"mach"`` so a plain + Cd-vs-Mach curve maps to Mach. Ignored when ``depends_on`` is given. + Default ``None``. + """ + self.name = name + self.extrapolation = extrapolation + self.unsteady_aero = unsteady_aero + self.control_variables = tuple(control_variables) + self.independent_vars = tuple( + build_independent_vars(unsteady_aero, control_variables) + ) + # Infer the stored source and its dependencies from the raw input when + # ``depends_on`` is not given. ``_resolve_input`` may also adopt the + # input's extrapolation (re-keying an AeroCoefficient), so refresh the + # local ``extrapolation`` used by the source-storage block below. + if depends_on is None: + source, depends_on = self._resolve_input(source, single_var) + extrapolation = self.extrapolation + # ``depends_on`` is kept in the given order because it matches the + # positional argument order of the stored source (callable parameters, + # CSV columns, …). ``_indices`` therefore maps the full argument tuple + # 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 extrapolation when explicitly asked, and on a copy: + # the source may be a user-owned Function reused elsewhere, so + # mutating it in place (e.g. drag forcing "constant") would change + # its behavior everywhere the caller reuses it. + if extrapolation is not None: + source = copy.deepcopy(source) + source.set_extrapolation(extrapolation) + self.function = source + elif callable(source): + self.function = Function( + source, + list(self.depends_on) or ["x"], + [name], + interpolation="linear", + extrapolation=extrapolation or "natural", + ) + else: + # Scalar constant. + self._constant = float(source) + self.is_zero = self._constant == 0.0 + self.function = Function(self._constant) + + self._evaluate = self.function.get_value_opt + + def _resolve_input(self, source, single_var): + """Infer ``(stored source, depends_on)`` from a raw coefficient input. + + Mirrors the coefficient inputs accepted by :class:`GenericSurface`: a + number, a callable, a :class:`Function`, a list/tuple of data points, a + path to a CSV (or other text) file, or another :class:`AeroCoefficient` + (re-keyed). + Called by :meth:`__init__` when ``depends_on`` is omitted; the returned + ``source`` is a number, a callable or a :class:`Function`, which the + constructor's source-storage block then stores. + """ + name = self.name + independent_vars = self.independent_vars + n_vars = len(independent_vars) + + if isinstance(source, AeroCoefficient): + # An already-built coefficient passed straight through, re-keyed to + # this surface's variable order. This is how a *surface* round-trips: + # GenericSurface/ControllableGenericSurface store their processed + # AeroCoefficients in ``to_dict`` and feed them back on ``from_dict`` + # (and a user may reuse one coefficient across surfaces). Adopt its + # extrapolation when none was requested. + if self.extrapolation is None: + self.extrapolation = source.extrapolation + value = ( + source._constant if source._constant is not None else source.function + ) + return value, source.depends_on + + if isinstance(source, str): + if source.lower().endswith(".csv"): + return self._load_csv( + source, + name, + independent_vars, + extrapolation=self.extrapolation or "natural", + single_var=single_var, + ) + # Any other path (e.g. a whitespace-delimited ``.txt`` curve) is read + # by Function, which auto-detects the delimiter. Linear interpolation + # matches the CSV loader, so the same data gives identical results + # whatever file form it is given. Falls through to the Function + # branch below (a 1-D table keyed to ``single_var``). + source = Function(source, interpolation="linear") + + # A list/tuple of data points is parsed by Function and handled below. + # Linear interpolation matches the CSV loader, so the same tabular data + # gives identical results whether supplied as a list or a CSV file + # (Function would otherwise default to spline). + if isinstance(source, (list, tuple)): + try: + source = Function(list(source), interpolation="linear") + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid list/tuple input for {name}: could not be parsed " + "into a Function of the independent variables." + ) from exc + + if isinstance(source, Function): + dom_dim = source.__dom_dim__ + if dom_dim == n_vars: + return source, list(independent_vars) + if dom_dim == 1: + # A 1-D Function depends on ``single_var`` when given, else on + # the first independent variable unless its input name matches. + return source, [ + single_var or self._infer_single_var(source, independent_vars) + ] + raise ValueError( + f"{name} Function must have {n_vars} input arguments " + f"({', '.join(independent_vars)}) or be one-dimensional." + ) + + if callable(source): + return source, self._infer_callable_depends_on( + source, independent_vars, name, single_var=single_var + ) + + # Anything else must be a scalar number. + try: + float(source) + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid input for {name}: must be a number, a CSV file path, " + "a list of data points, a callable, or a Function." + ) from exc + return source, () + + @staticmethod + def _load_csv( + file_path, name, independent_vars, extrapolation="natural", single_var=None + ): # pylint: disable=too-many-statements + """Load a coefficient CSV at minimal dimension. + + Expects header-based CSV data whose columns (except the last) are + independent variables among ``independent_vars``; the last column is the + coefficient value. The coefficient is stored over only the columns that + are present, in their header order. A headerless two-column file is + treated as a one-dimensional table over ``single_var``. + + Parameters + ---------- + 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 + ``"natural"``; drag coefficients pass ``"constant"``. + single_var : str, optional + Independent variable a headerless two-column table depends on. + Defaults to the first independent variable. + + Returns + ------- + 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="linear", + 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, + ) + if csv_func is None: + csv_func = Function( + file_path, + interpolation="linear", + extrapolation=extrapolation, + ) + + # The CSV columns may appear in any order; AeroCoefficient maps the full + # argument tuple to ``ordered_present_columns`` order, so the stored + # Function is queried directly at its own (minimal) dimensionality. + return csv_func, ordered_present_columns + + @staticmethod + def _infer_single_var(function, independent_vars): + """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): + """Infer ``depends_on`` for a plain callable. + + Conventions are accepted in order: + + 0. *Single variable* - when ``single_var`` is given and the callable + takes a single argument, it depends on that one variable regardless + of the parameter name (e.g. a Mach-only drag ``lambda mach: ...``). + 1. *Named subset* - every parameter name is an independent variable, so + the parameters themselves name the dependency subset (e.g. + ``lambda alpha, mach: ...``). + 2. *Positional full-arity* - the parameter count equals the number of + independent variables, so the callable depends on all of them + regardless of how its parameters are named (e.g. + ``lambda a, b, m, r, p, q, rr: ...``). + """ + 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): + """Back-compat alias used by the linear model's hot-loop term skipping.""" + return self.is_zero + + @property + def __dom_dim__(self): + """Number of full independent variables (the call arity).""" + return len(self.independent_vars) + + def get_value_opt(self, *args): + """Fast, unvalidated evaluation (mirrors :meth:`Function.get_value_opt`). + + Maps the full ``independent_vars`` argument tuple down to the source's + own ``depends_on`` arguments before evaluating; a constant short-circuits. + """ + if self._constant is not None: + return self._constant + return self._evaluate(*(args[i] for i in self._indices)) + + # Calling the coefficient is the same as the fast evaluator; the linear + # model grabs ``get_value_opt`` directly for the hot loop. + __call__ = get_value_opt + + def __mul__(self, other): + """Scale the coefficient by ``other``, returning a new AeroCoefficient. + + Used by the Monte Carlo drag factor (``coefficient *= factor``). The + underlying constant or :class:`Function` is scaled while ``depends_on``, + the independent-variable axes and ``extrapolation`` are preserved. + """ + source = self._constant if self._constant is not None else self.function + return AeroCoefficient( + source * other, + self.depends_on, + self.unsteady_aero, + self.control_variables, + self.name, + extrapolation=self.extrapolation, + ) + + __rmul__ = __mul__ + + def to_dict(self, **kwargs): # pylint: disable=unused-argument + """Serialize the coefficient for :class:`rocketpy._encoders.RocketPyEncoder`.""" + return { + "source": self._constant if self._constant is not None else self.function, + "depends_on": list(self.depends_on), + "unsteady_aero": self.unsteady_aero, + "control_variables": list(self.control_variables), + "name": self.name, + "extrapolation": self.extrapolation, + } + + @classmethod + def from_dict(cls, data): + """Rebuild an :class:`AeroCoefficient` from its :meth:`to_dict` form.""" + return cls( + data["source"], + data["depends_on"], + data.get("unsteady_aero", False), + data.get("control_variables", ()), + data["name"], + extrapolation=data.get("extrapolation"), + ) + + def __repr__(self): + """Return a concise representation showing the constant or dependencies.""" + if self._constant is not None: + return f"AeroCoefficient({self.name}={self._constant})" + return f"AeroCoefficient({self.name}, depends_on={self.depends_on})" diff --git a/rocketpy/rocket/aero_surface/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..6011dd884 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -6,12 +6,14 @@ from rocketpy.plots.aero_surface_plots import _AirBrakesPlots from rocketpy.prints.aero_surface_prints import _AirBrakesPrints -from .aero_surface import AeroSurface +from .controllable_generic_surface import ControllableGenericSurface -# TODO: review airbrakes implementation to make it more in line with events -class AirBrakes(AeroSurface): - """AirBrakes class. Inherits from AeroSurface. +class AirBrakes(ControllableGenericSurface): + """AirBrakes class. Inherits from :class:`ControllableGenericSurface`, using + ``deployment_level`` as its single control variable and a multivariable drag + coefficient. Air brakes are summed in the equations of motion through the + standard aerodynamic-surface loop, like any other generic surface. Attributes ---------- @@ -35,6 +37,10 @@ class AirBrakes(AeroSurface): Name of the air brakes. """ + # When True, the rocket applies the air-brake force at the center of dry + # mass (zero moment arm). Set by Rocket when no position is given. + _pin_cp_to_cdm = False + def __init__( self, drag_coefficient_curve, @@ -68,12 +74,13 @@ def __init__( - If a Function, it must take two parameters: deployment level and Mach number, and return the drag coefficient. - .. note:: For ``override_rocket_drag = False``, at - deployment level 0, the drag coefficient is assumed to be 0, - independent of the input drag coefficient curve. This means that - the simulation always considers that at a deployment level of 0, - the air brakes are completely retracted and do not contribute to - the drag of the rocket. + .. note:: At deployment level 0 the drag coefficient is assumed + to be 0, independent of the input drag coefficient curve. This + means that the simulation always considers that at a deployment + level of 0, the air brakes are completely retracted and do not + contribute to the drag of the rocket (and, for + ``override_rocket_drag = True``, the rocket body drag applies + normally while the brakes are retracted). reference_area : int, float Reference area used to calculate the drag force of the air brakes @@ -100,89 +107,79 @@ 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. Retracted air brakes always + # contribute zero drag; when ``override_rocket_drag`` is set, the + # rocket body drag is suppressed while the brakes are deployed (see + # the flight derivatives), so this surface then carries the whole + # vehicle 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: + 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 + # Re-capture so ``_reset`` restores the (possibly clamped) initial + # deployment level rather than the base class default of 0. + self.initial_control_state = dict(self.control_state) 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 - - def _reset(self): - """Resets the air brakes to their initial state. This is ran at the - beginning of each simulation to ensure the air brakes are in the correct - 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. + return value - 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. + @property + def deployment_level(self): + """Returns the deployment level of the air brakes.""" + return self.control_state["deployment_level"] - 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", - ) + @deployment_level.setter + def deployment_level(self, value): + self.set_control("deployment_level", value) def evaluate_geometrical_parameters(self): """Evaluates the geometrical parameters of the aerodynamic surface. @@ -211,7 +208,9 @@ def all_info(self): self.info() self.plots.drag_coefficient_curve() - def to_dict(self, **kwargs): # pylint: disable=unused-argument + def to_dict( # pylint: disable=unused-argument + self, include_outputs=False, **kwargs + ): return { "drag_coefficient_curve": self.drag_coefficient, "reference_area": self.reference_area, @@ -219,11 +218,12 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "override_rocket_drag": self.override_rocket_drag, "deployment_level": self.initial_deployment_level, "name": self.name, + "position_pinned_to_cdm": self._pin_cp_to_cdm, } @classmethod def from_dict(cls, data): - return cls( + air_brakes = cls( drag_coefficient_curve=data.get("drag_coefficient_curve"), reference_area=data.get("reference_area"), clamp=data.get("clamp"), @@ -231,3 +231,6 @@ def from_dict(cls, data): deployment_level=data.get("deployment_level"), name=data.get("name"), ) + # Legacy files (no key) were always applied with zero moment arm. + air_brakes._pin_cp_to_cdm = data.get("position_pinned_to_cdm", True) + return air_brakes 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..6ae4d3615 --- /dev/null +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -0,0 +1,194 @@ +from rocketpy.rocket.aero_surface.generic_surface import GenericSurface + + +class ControllableGenericSurface(GenericSurface): + """A generic aerodynamic surface whose coefficients additionally depend on + one or more **control-deflection** variables (canards, grid fins, elevons, + air-brake deployment, …) sourced at runtime from a controller. + + On top of the seven standard independent variables of + :class:`GenericSurface` (``alpha``, ``beta``, ``mach``, ``reynolds``, + ``pitch_rate``, ``yaw_rate``, ``roll_rate``), the coefficient functions take + one extra argument per entry of ``controls`` (appended in order). The + current control values are held in :attr:`control_state` and mutated each + simulation step by a controller (see ``Rocket.add_controllable_surface``); + :meth:`_coefficient_arguments` appends them to every coefficient evaluation. + + Attributes + ---------- + ControllableGenericSurface.control_variables : list of str + Names of the control-deflection axes, in coefficient-argument order. + ControllableGenericSurface.control_state : dict + Current value of each control variable (defaults to 0). + """ + + # TODO: deflection-dependent static-margin diagnostics. + # + # The in-flight dynamics are correct: the deflection feeds the coefficient + # functions live every step (see ``_coefficient_arguments``), and the surface + # never physically moves, so its force-application point / ``cp_to_cdm`` cache + # cannot go stale (unlike an individual fin's cant angle, which IS a physical + # reconfiguration and is refreshed via ``Rocket.refresh_controlled_components``). + # + # The gap is diagnostic-only. The derived ``center_of_pressure_z`` / + # ``aerodynamic_center`` come from ``cm_alpha = d(cm)/d(alpha)`` evaluated ONCE + # (in ``_set_derived_cp_accessors``) with the control variables frozen at their + # value at construction (0). So if ``cm`` couples alpha and a control axis + # (e.g. an ``alpha * deflection`` term), the reported ``static_margin`` is + # pinned to the zero-deflection configuration and does not track ``set_control``. + # It also is not a single well-defined number: the static margin of a deflected + # control surface is inherently a function of the control input. + # + # To address this properly (not a correctness fix, defer until there is a real + # need), likely some combination of: + # - an ``initial_deflection`` (per-control) argument in ``__init__`` so the + # derived cp accessors are built about a chosen reference deflection rather + # than always 0; + # - re-deriving the cp accessors when the deflection changes -- reuse the + # fin mechanism: bump ``_geometry_version`` in ``set_control`` and have + # ``Rocket.refresh_controlled_components`` re-run the derived-cp step; + # - dedicated stability plots/prints that sweep the static margin (and cp) + # OVER the control-deflection range, since a single scalar margin is the + # wrong abstraction for a controllable surface. + + def __init__( + self, + reference_area, + reference_length, + coefficients, + center_of_pressure=(0, 0, 0), + name="Controllable Generic Surface", + controls=("deflection",), + ): + """Create a controllable generic aerodynamic surface. + + Parameters + ---------- + reference_area : int, float + Reference area of the surface, in squared meters. + reference_length : int, float + Reference length of the surface, in meters. + coefficients : dict + Aerodynamic coefficients (``cL``, ``cQ``, ``cD``, ``cm``, ``cn``, + ``cl``), each a callable/CSV/Function of the seven base variables + **plus** the control variables listed in ``controls`` (appended in + order). Omitted coefficients default to 0. + center_of_pressure : tuple, list, optional + Application point of the aerodynamic forces and moments in the local + surface frame. Default ``(0, 0, 0)``. + name : str, optional + Name of the surface. Default ``"Controllable Generic Surface"``. + controls : iterable of str, optional + Names of the control-deflection axes. Default ``("deflection",)``. + Each name becomes an extra coefficient argument and a key in + :attr:`control_state`. + """ + # These must be set before ``super().__init__`` so coefficient + # processing (arity, CSV validation) and the derived-cp accessors see + # the extended variable list (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, + ) + # ``self.prints``/``self.plots`` are the generic ones wired by the base. + self.initial_control_state = dict(self.control_state) + + 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 _reset(self): + """Restore all control variables to their initial values. Ran by the + controller at the beginning of each simulation so control state does + not leak across flights.""" + for name, value in self.initial_control_state.items(): + self.set_control(name, value) + + def to_dict( # pylint: disable=unused-argument + self, include_outputs=False, **kwargs + ): + return { + "reference_area": self.reference_area, + "reference_length": self.reference_length, + "coefficients": { + "cL": self.cL, + "cQ": self.cQ, + "cD": self.cD, + "cm": self.cm, + "cn": self.cn, + "cl": self.cl, + }, + "center_of_pressure": self.center_of_pressure, + "name": self.name, + "controls": self.control_variables, + } + + @classmethod + def from_dict(cls, data): + return cls( + reference_area=data["reference_area"], + reference_length=data["reference_length"], + coefficients=data["coefficients"], + center_of_pressure=data.get("center_of_pressure", (0, 0, 0)), + name=data.get("name", "Controllable Generic Surface"), + controls=data.get("controls", ("deflection",)), + ) diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py index f6b09f797..332326aea 100644 --- a/rocketpy/rocket/aero_surface/fins/_base_fin.py +++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py @@ -5,13 +5,15 @@ from rocketpy.mathutils.function import Function -from ..aero_surface import AeroSurface +from .._barrowman_surface import _BarrowmanSurface +from ..linear_generic_surface import LinearGenericSurface -class _BaseFin(AeroSurface): +class _BaseFin(_BarrowmanSurface): """ Base class for fins, shared by both Fin and Fins classes. - Inherits from AeroSurface. + Inherits from :class:`_BarrowmanSurface`, translating the fin geometry into + the linear generic-surface coefficient model. Handles shared initialization logic and common properties. """ @@ -47,8 +49,13 @@ def __init__( self.geometry = None self.reference_area = np.pi * rocket_radius**2 + self.reference_length = self.rocket_diameter - super().__init__(name, self.reference_area, self.rocket_diameter) + # The linear generic-surface machinery is initialized lazily by + # ``_finalize_barrowman`` once the concrete subclass has set up its + # geometry strategy and the first ``_update_geometry_chain`` has + # produced ``clalpha``, ``cpz`` and ``roll_parameters``. + self._barrowman_initialized = False def _update_reference_quantities(self): """Update quantities that depend on rocket radius.""" @@ -56,11 +63,32 @@ def _update_reference_quantities(self): self.reference_length = self.rocket_diameter def _update_geometry_chain(self): - """Update geometry-dependent quantities in dependency order.""" + """Update geometry-dependent quantities in dependency order, then + (re)build the generic-surface coefficients from the new geometry.""" self.evaluate_geometrical_parameters() self.evaluate_center_of_pressure() self.evaluate_lift_coefficient() self.evaluate_roll_parameters() + if self._barrowman_initialized: + # Geometry changed after construction: refresh the coefficients. + self.evaluate_coefficients() + self.compute_all_coefficients() + self._evaluate_derived_coefficients() + else: + self._finalize_barrowman() + + def _finalize_barrowman(self): + """Initialize the linear generic-surface machinery from the geometry + computed by the first ``_update_geometry_chain`` call.""" + LinearGenericSurface.__init__( + self, + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=self.name, + ) + self._barrowman_initialized = True @property def rocket_radius(self): diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py index f809bca29..249a8cf70 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py @@ -175,8 +175,8 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py index 5fec8a099..b2875e658 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -91,6 +91,12 @@ class Fin(_BaseFin): damping coefficient and the cant angle in radians. """ + # A single fin contributes unequally to the pitch and yaw planes + # (``cL_alpha`` ~ sin^2(phi), ``cQ_beta`` ~ cos^2(phi)), so it is not + # axisymmetric on its own. A complete, evenly spaced set may still be + # axisymmetric collectively, which the rocket's numeric check resolves. + is_axisymmetric = False + def __init__( self, angular_position, @@ -148,6 +154,21 @@ def __init__( self._angular_position = angular_position self._angular_position_rad = math.radians(angular_position) + def _update_geometry_chain(self): + """Run the base geometry/coefficient chain, then (re)build the body<->fin + rotation matrices. + + The rotation matrices must be set **after** the chain: the chain's first + call initializes the generic-surface machinery, which resets + ``_rotation_surface_to_body`` to the identity. Doing it here (rather than + in each concrete fin's ``__init__``) ensures every individual-fin + subclass -- trapezoidal, elliptical and free-form -- gets correct, + angular-position-aware rotation matrices on construction and whenever the + geometry changes. + """ + super()._update_geometry_chain() + self.evaluate_rotation_matrix() + @property def cant_angle(self): return self._cant_angle @@ -281,7 +302,12 @@ def evaluate_rotation_matrix(self): sin_delta = math.sin(delta) cos_delta = math.cos(delta) - # Rotation about body Z by angular position + # The body -> fin change of basis is composed right-to-left as + # ``R_delta @ R_phi @ R_pi`` (R_pi first, R_delta last). Each factor + # therefore acts on the coordinates produced by the factors to its right, + # i.e. in the *current* (partially rotated) frame, not the body frame. + + # Roll by the angular position, about the rocket longitudinal axis. R_phi = Matrix( [ [cos_phi, -sin_phi, 0], @@ -290,7 +316,12 @@ def evaluate_rotation_matrix(self): ] ) - # Cant rotation about body Y + # Cant rotation about the fin **span (y) axis**. Because R_delta is the + # leftmost factor, it acts on coordinates already in the rolled + # uncanted-fin frame, so it rotates about that frame's y axis (the fin's + # own root-to-tip direction) -- NOT body Y, with which it coincides only + # at angular_position = 0. This is what makes each fin cant about its own + # span; using body Y (``R_uncanted @ R_delta``) would be wrong. R_delta = Matrix( [ [cos_delta, 0, -sin_delta], @@ -299,7 +330,9 @@ def evaluate_rotation_matrix(self): ] ) - # 180 flip about Y to align fin leading/trailing edge + # 180 flip about Y so the uncanted fin z axis points leading -> trailing + # edge (toward the tail, i.e. -body z), with x completing a right-handed + # frame. Proper rotation (det +1), not a reflection. R_pi = Matrix( [ [-1, 0, 0], @@ -308,7 +341,7 @@ def evaluate_rotation_matrix(self): ] ) - # Uncanted body to fin, then apply cant + # Uncanted body -> fin, then apply the cant in the fin span frame. R_uncanted = R_phi @ R_pi R_body_to_fin = R_delta @ R_uncanted @@ -318,6 +351,41 @@ def evaluate_rotation_matrix(self): self._rotation_fin_to_body = R_body_to_fin.transpose self._rotation_surface_to_body = self._rotation_fin_to_body + @property + def force_application_point(self): + """A single (off-axis) fin keeps its bespoke force computation and + transports the moment geometrically through its center of pressure, + so the force application point is the fin's actual cp rather than the + surface origin used by axisymmetric Barrowman surfaces. + """ + return Vector([self.cpx, self.cpy, self.cpz]) + + def evaluate_coefficients(self): + """A single fin transports its moment geometrically (via ``cp ^ force`` + in its own ``compute_forces_and_moments``), so only the normal-force + slopes are exposed for the stability-margin diagnostic; the moment + coefficients stay zero to avoid double-counting the cp offset. + + A fin's lift only resists incidence in its own plane, so its slope is + projected onto the pitch and yaw planes by its angular position + ``phi``: ``sin(phi)**2`` to the pitch plane (``cL_alpha``) and + ``cos(phi)**2`` to the yaw plane (``cQ_beta``). A fin at ``phi = 0`` + (lying in the yaw plane) thus feeds the yaw plane only, which is what + makes a non-axisymmetric individual-fin layout report different pitch- + and yaw-plane centers of pressure. An evenly spaced set of ``n`` fins + sums to ``n / 2`` in each plane, reproducing the axisymmetric ``Fins`` + set (see :meth:`Fins.fin_num_correction`). + """ + clalpha = self.clalpha + sin_sq = math.sin(self.angular_position_rad) ** 2 + cos_sq = math.cos(self.angular_position_rad) ** 2 + self.cL_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach) * sin_sq + ) + self.cQ_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach) * cos_sq + ) + def compute_forces_and_moments( self, stream_velocity, diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index b61bfcc19..9913b3f5b 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -200,11 +200,18 @@ def evaluate_roll_parameters(self): """ clf_delta = ( self.roll_forcing_interference_factor - * self.fin_num_correction(self.n) + * self.n * (self.Yma + self.rocket_radius) * self.clalpha_single_fin / self.reference_length ) # Function of mach number + # NOTE: roll forcing scales with the full fin count ``n`` -- every + # identically-canted fin contributes the same roll moment, with no + # cancellation. This differs from the normal-force slope, which uses the + # ``fin_num_correction(n)`` (~n/2) multiple-fin factor because fins at + # different roll angles partially cancel in pitch/yaw. Using + # ``fin_num_correction(n)`` here previously halved the roll forcing (and + # the roll rate) of a fin set relative to the equivalent individual fins. clf_delta.set_inputs("Mach") clf_delta.set_outputs("Roll moment forcing coefficient derivative") clf_delta.set_title( @@ -251,66 +258,6 @@ def fin_num_correction(n): else: return n / 2 - def compute_forces_and_moments( - self, - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - omega, - *args, - ): # pylint: disable=arguments-differ - """Computes the forces and moments acting on the aerodynamic surface. - - Parameters - ---------- - stream_velocity : tuple of float - The velocity of the airflow relative to the surface. - stream_speed : float - The magnitude of the airflow speed. - stream_mach : float - The Mach number of the airflow. - rho : float - Air density. - cp : Vector - Center of pressure coordinates in the body frame. - omega: tuple[float, float, float] - Tuple containing angular velocities around the x, y, z axes. - - Returns - ------- - tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. - """ - - R1, R2, R3, M1, M2, _ = super().compute_forces_and_moments( - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - ) - clf_delta, cld_omega, cant_angle_rad = self.roll_parameters - M3_forcing = ( - (1 / 2 * rho * stream_speed**2) - * self.reference_area - * self.reference_length - * clf_delta.get_value_opt(stream_mach) - * cant_angle_rad - ) - M3_damping = ( - (1 / 2 * rho * stream_speed) - * self.reference_area - * (self.reference_length) ** 2 - * cld_omega.get_value_opt(stream_mach) - * omega[2] - / 2 - ) - M3 = M3_forcing + M3_damping - return R1, R2, R3, M1, M2, M3 - def to_dict(self, **kwargs): if self.airfoil: if kwargs.get("discretize", False): diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py index 5099d322b..bf6565010 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fin.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py @@ -172,8 +172,8 @@ def evaluate_center_of_pressure(self): def shape_points(self): return self.geometry.shape_points - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py index 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/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index 4b83e0e4f..d9f6fe82a 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,11 +1,16 @@ import copy -import csv import math import numpy as np from rocketpy.mathutils import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector +from rocketpy.plots.aero_surface_plots import _GenericSurfacePlots +from rocketpy.prints.aero_surface_prints import _GenericSurfacePrints +from rocketpy.rocket.aero_surface.aero_coefficient import ( + AeroCoefficient, + build_independent_vars, +) class GenericSurface: @@ -14,6 +19,14 @@ class GenericSurface: attack, sideslip angle, Mach number, Reynolds number, pitch rate, yaw rate and roll rate.""" + #: Whether this surface contributes identically to the pitch and yaw planes + #: *by construction*. Conservatively ``False`` for a generic surface (its + #: coefficients may differ between planes); the built-in axisymmetric + #: surfaces override it to ``True``. The rocket uses it to skip the numeric + #: pitch/yaw axisymmetry check when every surface is symmetric by + #: construction. + is_axisymmetric = False + def __init__( self, reference_area, @@ -21,6 +34,7 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Surface", + unsteady_aero=False, ): """Create a generic aerodynamic surface, defined by its aerodynamic coefficients. This surface is used to model any aerodynamic surface @@ -35,6 +49,13 @@ def __init__( "reynolds", "pitch_rate", "yaw_rate" and "roll_rate". The independent variable columns can be provided in any order. + The angular-rate inputs ("pitch_rate", "yaw_rate", "roll_rate") are the + conventional **non-dimensional reduced rates**, ``q* = q * L_ref / (2 * V)`` + (and likewise for ``r``/``p``), matching how published and tool-generated + aerotables (Missile DATCOM, OpenVSP, CFD/wind-tunnel data) tabulate rate + derivatives. Provide coefficient tables against the reduced rates, not the + raw body rates in rad/s. + See Also -------- :ref:`genericsurfaces`. @@ -74,8 +95,35 @@ def __init__( aerodynamic surface. The default value is (0, 0, 0). name : str, optional Name of the aerodynamic surface. Default is 'GenericSurface'. + unsteady_aero : bool, optional + If True, the coefficients additionally depend on the time + derivatives of the flow angles, and ``alpha_dot`` and ``beta_dot`` + are appended (in that order) to the independent variables. CSV files + may then include "alpha_dot"/"beta_dot" columns, and callables must + accept the two extra trailing arguments. The simulation supplies 0 + for these unless it computes them, so existing coefficient tables are + unaffected. Default is False. """ + # The independent variables of the coefficients are derived (see the + # ``independent_vars`` property) from ``unsteady_aero`` and, for + # subclasses, ``control_variables``. When ``unsteady_aero`` is enabled, + # the time-derivatives of the flow angles (``alpha_dot``, ``beta_dot``) + # become extra axes (defaulting to 0 at runtime, so existing tables are + # unaffected). Subclasses that add externally-supplied axes set + # ``control_variables`` before calling ``super().__init__``. + self._unsteady_aero = unsteady_aero + # Externally-supplied axes (e.g. control deflections). Subclasses set + # this before ``super().__init__``; defaults to none for plain surfaces. + self.control_variables = getattr(self, "control_variables", ()) + # Ordered independent variables accepted by every coefficient: the seven + # base axes, plus ``alpha_dot``/``beta_dot`` when ``unsteady_aero`` is + # enabled (integrator-supplied), plus any ``control_variables`` a + # subclass appended (externally supplied). Fixed at construction. + self.independent_vars = build_independent_vars( + self._unsteady_aero, self.control_variables + ) + self.reference_area = reference_area self.reference_length = reference_length self.center_of_pressure = center_of_pressure @@ -91,9 +139,184 @@ def __init__( self._check_coefficients(coefficients, default_coefficients) coefficients = self._complete_coefficients(coefficients, default_coefficients) for coeff, coeff_value in coefficients.items(): - value = self._process_input(coeff_value, coeff) + value = AeroCoefficient( + coeff_value, + unsteady_aero=self._unsteady_aero, + control_variables=self.control_variables, + name=coeff, + ) setattr(self, coeff, value) + self.evaluate_coefficients() + self._evaluate_derived_coefficients() + + # Reporting layers. Subclasses override these with their own (more + # specific) prints/plots after calling ``super().__init__``. + self.prints = _GenericSurfacePrints(self) + self.plots = _GenericSurfacePlots(self) + + @property + def force_application_point(self): + """Local point (surface frame) at which the resultant force is applied + when transporting its moment to the rocket's center of dry mass. For a + plain generic surface this is simply the center of pressure ``self.cp``; + the residual couple is carried by the ``cm``/``cn``/``cl`` coefficients. + Barrowman subclasses override this to the origin, because they fold the + whole cp offset into the moment coefficients instead. + """ + return Vector([self.cpx, self.cpy, self.cpz]) + + def 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_derived_coefficients(self): + """Build the mach-only diagnostic accessors used by the rocket's + center-of-pressure / stability-margin computation, for both the pitch + and the yaw plane. + + These reconstruct, at the linearization point ``alpha = beta = 0`` with + zero rates, each plane's force-curve slope and the location of its + center of pressure. The center of pressure combines the surface's + declared local ``cpz`` with the offset implied by its moment + coefficient (the two representations are interchangeable; + ``cpz_eff = cpz - (dc_moment/dangle)/(dc_force/dangle) * L_ref``): + + - pitch plane: ``lift_coefficient_derivative`` (``dcL/dalpha``) and + ``center_of_pressure_z`` (from ``cm``); + - yaw plane: ``side_coefficient_derivative`` and + ``center_of_pressure_z_yaw`` (from ``cn``). + + Returns + ------- + None + """ + cL_alpha = self._partial_slope(self.cL, axis="alpha") + cm_alpha = self._partial_slope(self.cm, axis="alpha") + cQ_beta = self._partial_slope(self.cQ, axis="beta") + cn_beta = self._partial_slope(self.cn, axis="beta") + self._set_derived_cp_accessors(cL_alpha, cm_alpha, cQ_beta, cn_beta) + + def _set_derived_cp_accessors(self, cL_alpha, cm_alpha, cQ_beta, cn_beta): + """Store the pitch- and yaw-plane diagnostic accessors as mach-only + ``Function``s, guarding the moment/force division for zero-force + surfaces (which then drop out of the force-weighted cp average). + + Parameters + ---------- + cL_alpha : Function + Pitch-plane normal-force slope ``dcL/dalpha`` vs. mach. + cm_alpha : Function + Pitch-moment slope ``dcm/dalpha`` vs. mach. + cQ_beta : Function + Yaw-plane side-force slope ``dcQ/dbeta`` vs. mach. + cn_beta : Function + Yaw-moment slope ``dcn/dbeta`` vs. mach. + """ + reference_length = self.reference_length + local_cpz = self.force_application_point[2] + + def _cp_z(force_slope, moment_slope): + # Recover the center of pressure from a force slope and its matching + # moment slope, as a Function of Mach. + def cp_z(mach): + slope = force_slope.get_value_opt(mach) + # No force at this Mach -> the cp is undefined; fall back to the + # geometric application point so this surface contributes zero + # weight to the force-weighted cp average. + if slope == 0: + return local_cpz + # cp = application point - (moment slope / force slope) * L_ref. + return ( + local_cpz + - moment_slope.get_value_opt(mach) / slope * reference_length + ) + + return Function(cp_z, "Mach", "Center of pressure to local origin (m)") + + # Pitch plane. + self.lift_coefficient_derivative = cL_alpha + self.center_of_pressure_z = _cp_z(cL_alpha, cm_alpha) + + # Yaw plane. The side-force slope is sign-adjusted (``-cQ_beta``) so + # that an axisymmetric surface yields the same signed weight as the + # pitch plane, making the two planes' margins coincide when symmetric. + self.side_coefficient_derivative = -cQ_beta + self.center_of_pressure_z_yaw = _cp_z(cQ_beta, cn_beta) + + def _partial_slope(self, coefficient, axis): + """Partial derivative ``d(coefficient)/d(axis)`` at ``alpha = beta = 0`` + and zero rates, returned as a mach-only ``Function``. + + Reuses :meth:`Function.differentiate` on a single-variable slice of the + coefficient taken along ``axis`` (``"alpha"`` or ``"beta"``) with all + other base inputs frozen at zero. Extra axes (control deflections) are + frozen at their current value via :meth:`_coefficient_arguments`. + + Parameters + ---------- + coefficient : Function + A coefficient ``Function`` over ``self.independent_vars``. + axis : str + Either ``"alpha"`` or ``"beta"``. + + Returns + ------- + Function + ``d(coefficient)/d(axis)`` evaluated at the zero point, vs. mach. + """ + + def slope(mach): + if axis == "alpha": + sliced = Function( + lambda alpha: coefficient( + *self._coefficient_arguments( + alpha, 0.0, mach, 0.0, 0.0, 0.0, 0.0 + ) + ) + ) + else: + sliced = Function( + lambda beta: coefficient( + *self._coefficient_arguments( + 0.0, beta, mach, 0.0, 0.0, 0.0, 0.0 + ) + ) + ) + return sliced.differentiate(0) + + return Function(slope, "Mach", "Coefficient derivative") + def _get_default_coefficients(self): """Returns default coefficients @@ -173,6 +396,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,11 +417,11 @@ def _compute_from_coefficients( reynolds : float Reynolds number. pitch_rate : float - Pitch rate in radians per second. + Non-dimensional (reduced) pitch rate, ``q * L_ref / (2 * V)``. yaw_rate : float - Yaw rate in radians per second. + Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float - Roll rate in radians per second. + Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. Returns ------- @@ -208,30 +433,56 @@ def _compute_from_coefficients( dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area dyn_pressure_area_length = dyn_pressure_area * self.reference_length - # Compute aerodynamic forces - lift = dyn_pressure_area * self.cL( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - side = dyn_pressure_area * self.cQ( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - drag = dyn_pressure_area * self.cD( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + # Coefficient arguments (base 7 vars, plus any extra axes appended by + # subclasses such as control deflections or the unsteady alpha_dot/ + # beta_dot terms). + args = self._coefficient_arguments( + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + alpha_dot, + beta_dot, ) + # Compute aerodynamic forces + lift = dyn_pressure_area * self.cL(*args) + side = dyn_pressure_area * self.cQ(*args) + drag = dyn_pressure_area * self.cD(*args) + # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cm( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - yaw = dyn_pressure_area_length * self.cn( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - roll = dyn_pressure_area_length * self.cl( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + pitch = dyn_pressure_area_length * self.cm(*args) + yaw = dyn_pressure_area_length * self.cn(*args) + roll = dyn_pressure_area_length * self.cl(*args) return lift, side, drag, pitch, yaw, roll + def _coefficient_arguments( + self, + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + alpha_dot=0.0, + beta_dot=0.0, + ): + """Returns the argument tuple passed to every coefficient ``Function``, + in ``self.independent_vars`` order. The base class provides the seven + standard inputs, plus ``alpha_dot``/``beta_dot`` when ``unsteady_aero`` + is enabled. Subclasses (e.g. :class:`ControllableGenericSurface`) + override this to append further axes such as control deflections. + """ + base = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) + if self._unsteady_aero: + return base + (alpha_dot, beta_dot) + return base + def compute_forces_and_moments( self, stream_velocity, @@ -243,6 +494,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 @@ -295,6 +548,17 @@ def compute_forces_and_moments( alpha = np.arctan2(stream_velocity[1], stream_velocity[2]) beta = np.arctan2(stream_velocity[0], stream_velocity[2]) + # Non-dimensionalize the body angular rates into the conventional reduced + # rates (e.g. ``q* = q * L_ref / (2 * V)``) before evaluating the + # coefficients, so coefficient tables follow the standard aerotable + # convention (Missile DATCOM, OpenVSP, CFD/wind-tunnel data tabulate rate + # derivatives against the reduced rates). The factor is 0 at zero airspeed + # (pad/static) to avoid division by zero; there is no aerodynamic damping + # there anyway. + reduced_rate_factor = ( + self.reference_length / (2 * stream_speed) if stream_speed > 0 else 0.0 + ) + # Compute aerodynamic forces and moments lift, side, drag, pitch, yaw, roll = self._compute_from_coefficients( rho, @@ -303,23 +567,28 @@ def compute_forces_and_moments( beta, stream_mach, reynolds, - omega[0], # q - omega[1], # r - omega[2], # p + omega[0] * reduced_rate_factor, # q* reduced pitch rate + omega[1] * reduced_rate_factor, # r* reduced yaw rate + omega[2] * reduced_rate_factor, # p* reduced roll rate + alpha_dot, + beta_dot, ) - # Conversion from aerodynamic frame to body frame + # Conversion from the aerodynamic frame to the body frame. This is the + # direction cosine matrix (DCM) that expresses the aerodynamic-frame + # force components in the body frame, i.e. rotations by ``-alpha`` about + # x and ``+beta`` about y. rotation_matrix = Matrix( [ [1, 0, 0], - [0, math.cos(alpha), -math.sin(alpha)], - [0, math.sin(alpha), math.cos(alpha)], + [0, math.cos(alpha), math.sin(alpha)], + [0, -math.sin(alpha), math.cos(alpha)], ] ) @ Matrix( [ - [math.cos(beta), 0, -math.sin(beta)], + [math.cos(beta), 0, math.sin(beta)], [0, 1, 0], - [math.sin(beta), 0, math.cos(beta)], + [-math.sin(beta), 0, math.cos(beta)], ] ) R1, R2, R3 = rotation_matrix @ Vector([side, -lift, -drag]) @@ -328,159 +597,3 @@ def compute_forces_and_moments( M1, M2, M3 = Vector([pitch, yaw, roll]) + (cp ^ Vector([R1, R2, R3])) return R1, R2, R3, M1, M2, M3 - - def _process_input(self, input_data, coeff_name): - """Process 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..8bf2476ef 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -171,6 +171,29 @@ def __init__( self.prints = _LinearGenericSurfacePrints(self) self.plots = _LinearGenericSurfacePlots(self) + def _evaluate_derived_coefficients(self): + """Exact override of the diagnostic cp accessors. The linear model + already exposes the forcing derivatives ``cL_alpha``/``cm_alpha`` (pitch) + and ``cQ_beta``/``cn_beta`` (yaw), so the slopes are read directly + (frozen at zero alpha/beta/rates) instead of being recovered by + numerical differentiation. Damping derivatives (``_p/_q/_r``) are + intentionally excluded from the stability cp. + """ + + def _at_zero(coefficient, name): + return Function( + lambda mach: coefficient(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0), + "Mach", + name, + ) + + self._set_derived_cp_accessors( + _at_zero(self.cL_alpha, "cL_alpha"), + _at_zero(self.cm_alpha, "cm_alpha"), + _at_zero(self.cQ_beta, "cQ_beta"), + _at_zero(self.cn_beta, "cn_beta"), + ) + def _get_default_coefficients(self): """Returns default coefficients @@ -220,64 +243,119 @@ def _get_default_coefficients(self): } return default_coefficients - def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): - """Compute the forcing coefficient from the derivatives of the - aerodynamic coefficients.""" - - def total_coefficient( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ): - return ( - c_0(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - + c_alpha(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * alpha - + c_beta(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * beta - ) + _COEFFICIENT_INPUTS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], - ) + def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): + """Compose the forcing coefficient ``c_0 + c_alpha*alpha + c_beta*beta``, + evaluating only the non-zero terms. + + Two hot-loop optimizations: ``get_value_opt`` is the unvalidated fast + evaluator (for callable-source coefficients it is the raw source), and + terms that are identically zero are skipped entirely. For a Barrowman + surface each forcing coefficient has at most one non-zero derivative, so + this typically collapses to a single source call (or to a constant 0). + """ + has_0 = not getattr(c_0, "is_zero_coefficient", False) + has_alpha = not getattr(c_alpha, "is_zero_coefficient", False) + has_beta = not getattr(c_beta, "is_zero_coefficient", False) + c_0_opt = c_0.get_value_opt + c_alpha_opt = c_alpha.get_value_opt + c_beta_opt = c_beta.get_value_opt + + if not (has_0 or has_alpha or has_beta): + + def total_coefficient( # pylint: disable=unused-argument + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + return 0.0 + + else: + + def total_coefficient( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + value = 0.0 + if has_0: + value += c_0_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + if has_alpha: + value += ( + c_alpha_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * alpha + ) + if has_beta: + value += ( + c_beta_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * beta + ) + return value + + return Function(total_coefficient, self._COEFFICIENT_INPUTS, ["coefficient"]) def compute_damping_coefficient(self, c_p, c_q, c_r): - """Compute the damping coefficient from the derivatives of the - aerodynamic coefficients.""" - - def total_coefficient( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ): - return ( - c_p(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * roll_rate - + c_q(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * pitch_rate - + c_r(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * yaw_rate - ) - - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], - ) + """Compose the damping coefficient + ``c_p*roll_rate + c_q*pitch_rate + c_r*yaw_rate``, evaluating only the + non-zero terms (see :meth:`compute_forcing_coefficient`). For a Barrowman + surface only ``cl_p`` (roll damping) is non-zero, so most damping + coefficients collapse to a constant 0. + """ + has_p = not getattr(c_p, "is_zero_coefficient", False) + has_q = not getattr(c_q, "is_zero_coefficient", False) + has_r = not getattr(c_r, "is_zero_coefficient", False) + c_p_opt = c_p.get_value_opt + c_q_opt = c_q.get_value_opt + c_r_opt = c_r.get_value_opt + + if not (has_p or has_q or has_r): + + def total_coefficient( # pylint: disable=unused-argument + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + return 0.0 + + else: + + def total_coefficient( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + value = 0.0 + if has_p: + value += ( + c_p_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * roll_rate + ) + if has_q: + value += ( + c_q_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * pitch_rate + ) + if has_r: + value += ( + c_r_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * yaw_rate + ) + return value + + return Function(total_coefficient, self._COEFFICIENT_INPUTS, ["coefficient"]) def compute_all_coefficients(self): """Compute all the aerodynamic coefficients from the derivatives.""" @@ -312,6 +390,12 @@ def compute_all_coefficients(self): ) self.cld = self.compute_damping_coefficient(self.cl_p, self.cl_q, self.cl_r) + self.cL = self.cLf + self.cQ = self.cQf + self.cD = self.cDf + self.cm = self.cmf + self.cn = self.cnf + def _compute_from_coefficients( self, rho, @@ -323,10 +407,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,11 +431,11 @@ def _compute_from_coefficients( reynolds : float Reynolds number. pitch_rate : float - Pitch rate in radians per second. + Non-dimensional (reduced) pitch rate, ``q * L_ref / (2 * V)``. yaw_rate : float - Yaw rate in radians per second. + Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float - Roll rate in radians per second. + Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. Returns ------- @@ -354,57 +443,41 @@ def _compute_from_coefficients( The aerodynamic forces (lift, side_force, drag) and moments (pitch, yaw, roll) in the body frame. """ - # Precompute common values + # Precompute common values. The angular rates arrive already + # non-dimensionalized (reduced rates, e.g. ``q* = q * L_ref / (2 * V)``), + # so the rate-damping terms use the same dynamic-pressure scaling as the + # forcing terms: the ``L_ref / (2 * V)`` factor now lives in the rate + # itself, not in the scaling. (Algebraically identical to the previous + # ``0.5 * rho * V * A * L / 2`` damping scaling applied to raw rates.) dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area - dyn_pressure_area_damping = ( - 0.5 * rho * stream_speed * self.reference_area * self.reference_length / 2 - ) dyn_pressure_area_length = dyn_pressure_area * self.reference_length - dyn_pressure_area_length_damping = ( - 0.5 - * rho - * stream_speed - * self.reference_area - * self.reference_length**2 - / 2 - ) - # 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 - ) + # Evaluate the composed coefficients through the fast, unvalidated + # ``get_value_opt`` path (the composed coefficients are callable-source + # Functions, so this calls the closure directly, skipping the per-call + # ``__call__``/``get_value`` argument validation in the hot loop). + args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - 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 + # Compute aerodynamic forces (forcing + reduced-rate damping) + lift = dyn_pressure_area * ( + self.cLf.get_value_opt(*args) + self.cLd.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 + side = dyn_pressure_area * ( + self.cQf.get_value_opt(*args) + self.cQd.get_value_opt(*args) ) - - # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cmf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cmd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + drag = dyn_pressure_area * ( + self.cDf.get_value_opt(*args) + self.cDd.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 + # Compute aerodynamic moments (forcing + reduced-rate damping) + pitch = dyn_pressure_area_length * ( + self.cmf.get_value_opt(*args) + self.cmd.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 + yaw = dyn_pressure_area_length * ( + self.cnf.get_value_opt(*args) + self.cnd.get_value_opt(*args) + ) + roll = dyn_pressure_area_length * ( + self.clf.get_value_opt(*args) + self.cld.get_value_opt(*args) ) return lift, side, drag, pitch, yaw, roll diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py index 240a61a5c..a0c0507e7 100644 --- a/rocketpy/rocket/aero_surface/nose_cone.py +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -7,10 +7,10 @@ from rocketpy.plots.aero_surface_plots import _NoseConePlots from rocketpy.prints.aero_surface_prints import _NoseConePrints -from .aero_surface import AeroSurface +from ._barrowman_surface import _BarrowmanSurface -class NoseCone(AeroSurface): +class NoseCone(_BarrowmanSurface): """Keeps nose cone information. Note @@ -129,7 +129,9 @@ def __init__( # pylint: disable=too-many-statements None """ rocket_radius = rocket_radius or base_radius - super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + self.name = name + self.reference_area = np.pi * rocket_radius**2 + self.reference_length = 2 * rocket_radius self._rocket_radius = rocket_radius self._base_radius = base_radius @@ -163,6 +165,16 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_lift_coefficient() self.evaluate_center_of_pressure() + # Translate the Barrowman geometry (clalpha, cpz) into the linear + # generic-surface coefficient model and build the shared compute path. + super().__init__( + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=name, + ) + self.plots = _NoseConePlots(self) self.prints = _NoseConePrints(self) diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index 7d3a9bd30..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..0066bcf86 100644 --- a/rocketpy/rocket/aero_surface/tail.py +++ b/rocketpy/rocket/aero_surface/tail.py @@ -4,10 +4,10 @@ from rocketpy.plots.aero_surface_plots import _TailPlots from rocketpy.prints.aero_surface_prints import _TailPrints -from .aero_surface import AeroSurface +from ._barrowman_surface import _BarrowmanSurface -class Tail(AeroSurface): +class Tail(_BarrowmanSurface): """Class that defines a tail. Currently only accepts conical tails. Note @@ -76,7 +76,9 @@ def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail" ------- None """ - super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + self.name = name + self.reference_area = np.pi * rocket_radius**2 + self.reference_length = 2 * rocket_radius self._top_radius = top_radius self._bottom_radius = bottom_radius @@ -87,6 +89,16 @@ def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail" self.evaluate_lift_coefficient() self.evaluate_center_of_pressure() + # Translate the Barrowman geometry into the linear generic-surface + # coefficient model and build the shared compute path. + super().__init__( + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=name, + ) + self.plots = _TailPlots(self) self.prints = _TailPrints(self) diff --git a/rocketpy/rocket/effector/__init__.py b/rocketpy/rocket/effector/__init__.py new file mode 100644 index 000000000..81f8ddfce --- /dev/null +++ b/rocketpy/rocket/effector/__init__.py @@ -0,0 +1,2 @@ +from .effector import Effector +from .generic_effector import GenericEffector diff --git a/rocketpy/rocket/effector/effector.py b/rocketpy/rocket/effector/effector.py new file mode 100644 index 000000000..b07277157 --- /dev/null +++ b/rocketpy/rocket/effector/effector.py @@ -0,0 +1,119 @@ +class Effector: + """Abstract control effector: injects a body-frame force and/or moment + **directly** into the equations of motion. + + Unlike an aerodynamic surface -- whose force is produced by the aerodynamic + model from dynamic pressure -- an effector contributes an arbitrary body + force/moment computed from its control state (and, optionally, the flight + state). This is the non-aerodynamic control path: reaction-control thrusters, + reaction wheels, and (later) thrust vector control. It works regardless of + airspeed or atmosphere. + + An effector is a :class:`rocketpy.control.controlled_object.ControlledObject`: + a :class:`rocketpy.Controller` sets its control state each step (via + :meth:`set_control`), and the effector maps that state to a force/moment in + :meth:`evaluate`. Register it on a rocket with + :meth:`rocketpy.Rocket.add_effector`; the equations of motion then sum its + contribution alongside the aerodynamic surfaces. + + Naming note: this maps ``control_state -> force/moment`` (the *effector* + role). A future ``Actuator`` layer -- modelling actuator dynamics such as + rate/position limits and lag between commanded and realized control -- would + sit between the :class:`Controller` and the effector; ``_clamp_control`` is + the saturation seam it would build on. + + Attributes + ---------- + Effector.name : str + Human-readable name (used for controller rewiring on load). + Effector.position : float or tuple + Application station along the rocket axis (float), or a full ``(x, y, z)`` + point in the user coordinate system. Sets the moment arm for a pure force. + Effector.control_variables : list of str + Ordered names of the control axes. + Effector.control_state : dict + Current value of each control variable. + Effector.initial_control_state : dict + Control values restored at the start of each simulation. + """ + + def __init__(self, position=0.0, controls=("command",), name="Effector"): + """Initialize the effector. + + Parameters + ---------- + position : float or tuple, optional + Application station (float, axial) or ``(x, y, z)`` point in the user + coordinate system. Defaults to ``0.0``. + controls : iterable of str, optional + Names of the control axes. Each becomes a key in + :attr:`control_state`. Defaults to ``("command",)``. + name : str, optional + Effector name. Defaults to ``"Effector"``. + """ + self.name = name + self.position = position + self.control_variables = list(controls) + self.control_state = {name: 0.0 for name in self.control_variables} + self.initial_control_state = dict(self.control_state) + + # --- ControlledObject protocol -------------------------------------- # + def _clamp_control(self, name, value): # pylint: disable=unused-argument + """Hook to constrain a control value before storing. No clamping by + default; subclasses may override (e.g. thruster saturation).""" + return value + + def set_control(self, name, value): + """Set the current value of a control variable (applying any clamping).""" + 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 _reset(self): + """Restore all control variables to their initial values. Run by the + controller at the start of each simulation so control state does not + leak across flights.""" + for name, value in self.initial_control_state.items(): + self.set_control(name, value) + + # --- Equations-of-motion contribution ------------------------------- # + def evaluate(self, force_arm, time, velocity_body, omega, mach, environment): + """Return this effector's body-frame force and moment contribution. + + Parameters + ---------- + force_arm : Vector + Position of the effector's application point relative to the center + of dry mass, in the body frame (its moment arm). + time : float + Simulation time, in seconds. + velocity_body : Vector + Rocket velocity in the body frame. + omega : Vector + Body angular velocity ``(w1, w2, w3)``. + mach : float + Free-stream Mach number. + environment : Environment + The flight environment. + + Returns + ------- + tuple of Vector + ``(force_body, moment_body)`` -- the force and the moment **about the + center of dry mass**, both in the body frame. + """ + raise NotImplementedError("Effector subclasses must implement evaluate().") + + def to_dict(self, include_outputs=False, **kwargs): + raise NotImplementedError + + @classmethod + def from_dict(cls, data): + raise NotImplementedError diff --git a/rocketpy/rocket/effector/generic_effector.py b/rocketpy/rocket/effector/generic_effector.py new file mode 100644 index 000000000..6c00c9143 --- /dev/null +++ b/rocketpy/rocket/effector/generic_effector.py @@ -0,0 +1,125 @@ +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.tools import from_hex_decode, to_hex_encode + +from .effector import Effector + +_ZERO = Vector([0.0, 0.0, 0.0]) + + +class GenericEffector(Effector): + """A control effector defined by user-supplied force/moment functions. + + The non-aerodynamic analog of :class:`rocketpy.GenericSurface`: instead of + aerodynamic coefficients, you provide the body-frame **force** and/or + **moment** directly as functions of the control state and flight state. The + moment produced by a force at an offset application point is added + automatically (``force_arm x force``), so a pure lateral thruster only needs + a ``force`` function. + + Both functions take keyword arguments only and receive the current control + values (by control-variable name) plus ``time``, ``omega`` (body angular + velocity), ``velocity_body``, ``mach`` and ``environment``. + + Examples + -------- + A roll-torque effector driven by a controller:: + + rcs = GenericEffector( + moment=lambda **k: (0.0, 0.0, k["roll_torque"]), + controls=("roll_torque",), + name="Roll RCS", + ) + + A lateral (side-force) thruster at a nozzle station:: + + side = GenericEffector( + force=lambda **k: (0.0, k["side_force"], 0.0), + position=-1.2, + controls=("side_force",), + name="Side RCS", + ) + """ + + def __init__( + self, + force=None, + moment=None, + position=0.0, + controls=("command",), + name="Generic Effector", + ): + """Initialize the generic effector. + + Parameters + ---------- + force : callable, optional + ``force(**kwargs) -> (fx, fy, fz)`` body-frame force. Defaults to + ``None`` (zero force). + moment : callable, optional + ``moment(**kwargs) -> (mx, my, mz)`` body-frame moment about the + application point. Defaults to ``None`` (zero moment). + position : float or tuple, optional + Application station (axial float) or ``(x, y, z)`` point; sets the + moment arm for the force term. Defaults to ``0.0``. + controls : iterable of str, optional + Names of the control axes. Defaults to ``("command",)``. + name : str, optional + Effector name. Defaults to ``"Generic Effector"``. + """ + super().__init__(position=position, controls=controls, name=name) + self.force = force + self.moment = moment + + def evaluate(self, force_arm, time, velocity_body, omega, mach, environment): + """See :meth:`Effector.evaluate`. Returns the body-frame force and the + moment about the center of dry mass (own moment plus ``force_arm x + force``).""" + kwargs = dict(self.control_state) + kwargs.update( + time=time, + omega=omega, + velocity_body=velocity_body, + mach=mach, + environment=environment, + ) + force = Vector(list(self.force(**kwargs))) if self.force else _ZERO + moment = Vector(list(self.moment(**kwargs))) if self.moment else _ZERO + return force, moment + (force_arm ^ force) + + def to_dict(self, include_outputs=False, **kwargs): # pylint: disable=unused-argument + allow_pickle = kwargs.get("allow_pickle", True) + force = self.force + moment = self.moment + if allow_pickle: + force = to_hex_encode(force) if force is not None else None + moment = to_hex_encode(moment) if moment is not None else None + else: + force = getattr(force, "__name__", None) + moment = getattr(moment, "__name__", None) + return { + "force": force, + "moment": moment, + "position": self.position, + "controls": self.control_variables, + "name": self.name, + } + + @classmethod + def from_dict(cls, data): + force = data.get("force") + moment = data.get("moment") + try: + force = from_hex_decode(force) + except (TypeError, ValueError): + pass + try: + moment = from_hex_decode(moment) + except (TypeError, ValueError): + pass + return cls( + force=force if callable(force) else None, + moment=moment if callable(moment) else None, + position=data.get("position", 0.0), + controls=data.get("controls", ("command",)), + name=data.get("name", "Generic Effector"), + ) 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..a40443046 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,4 +1,3 @@ -import csv import inspect import math import warnings @@ -6,7 +5,9 @@ import numpy as np -from rocketpy.control.controller import _Controller +from rocketpy.control.air_brakes_controller import AirBrakesController +from rocketpy.control.controller import Controller +from rocketpy.control.surface_controller import SurfaceController from rocketpy.mathutils.function import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector from rocketpy.motors.empty_motor import EmptyMotor @@ -21,6 +22,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 @@ -28,11 +30,7 @@ from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute -from rocketpy.tools import ( - deprecated, - find_obj_from_hash, - parallel_axis_theorem_from_com, -) +from rocketpy.tools import deprecated, parallel_axis_theorem_from_com # pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-instance-attributes @@ -133,11 +131,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 +161,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 @@ -343,34 +344,65 @@ def __init__( # pylint: disable=too-many-statements self.sensors_by_name = {} self.aerodynamic_surfaces = Components() self.surfaces_cp_to_cdm = {} + self.effectors = [] + self.effectors_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. + # Drag is stored at its intrinsic dimensionality over the seven base + # independent variables; 1-D inputs are taken as Mach, and "constant" + # extrapolation is used (drag should not extrapolate beyond its range). + self.power_off_drag_7d = AeroCoefficient( + power_off_drag, + name="Drag Coefficient with Power Off", + extrapolation="constant", + single_var="mach", ) - self.power_on_drag_7d = self.__process_drag_input( - power_on_drag, "Drag Coefficient with Power On" + self.power_on_drag_7d = AeroCoefficient( + power_on_drag, + name="Drag Coefficient with Power On", + extrapolation="constant", + single_var="mach", ) self.power_on_drag_by_mach = Function( lambda mach: self.power_on_drag_7d(0, 0, mach, 0, 0, 0, 0), @@ -412,10 +444,15 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - # Evaluate stability (even though no aerodynamic surfaces are present yet) - self.evaluate_center_of_pressure() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # The aerodynamic center and the margins are evaluated lazily (see the + # ``aerodynamic_center`` / ``static_margin`` properties); just flag them + # outdated here. They are rebuilt on first access, once all surfaces and + # the motor have been added. + self._cp_outdated = True + self._margin_outdated = True + # One-shot guard for the non-axisymmetric advisory (see + # ``evaluate_center_of_pressure``); warned at most once per rocket. + self._axisymmetry_warned = False # Initialize plots and prints object self.prints = _RocketPrints(self) @@ -609,43 +646,367 @@ def evaluate_thrust_to_weight(self): self.thrust_to_weight.set_outputs("Thrust/Weight") self.thrust_to_weight.set_title("Thrust to Weight ratio") + # Lazily-evaluated aerodynamic outputs. + # + # The pitch/yaw aerodynamic centers and the static/stability margins are + # *derived* from the aerodynamic surfaces (and, for the margins, the center + # of mass). Rather than recompute them eagerly on every ``add_*`` call - an + # O(N^2) cost while building, repeated for every rocket in a Monte Carlo run - + # the mutating methods only flag them outdated; the value is rebuilt on first + # access and cached until the next change. ``_cp_outdated`` tracks the + # surface-dependent centers; ``_margin_outdated`` additionally tracks the + # center of mass, so adding a motor refreshes the margins without recomputing + # the surface-only aerodynamic center. + + def _ensure_aerodynamic_center(self): + """Recompute the pitch/yaw aerodynamic centers if a surface changed.""" + if self._cp_outdated: + self.evaluate_center_of_pressure() # clears ``_cp_outdated`` + + def _ensure_margins(self): + """Recompute the static/stability margins if a surface or the center of + mass changed. The underlying aerodynamic center is refreshed lazily by + the margin source closures.""" + if self._margin_outdated: + self._margin_outdated = False + self.evaluate_stability_margin() + self.evaluate_static_margin() + + @property + def aerodynamic_center(self): + """Pitch-plane aerodynamic center vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._aerodynamic_center + + @property + def aerodynamic_center_yaw(self): + """Yaw-plane aerodynamic center vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._aerodynamic_center_yaw + + @property + def total_lift_coeff_der(self): + """Total normal-force-coefficient derivative vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._total_lift_coeff_der + + @property + def total_side_coeff_der(self): + """Total side-force-coefficient derivative vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._total_side_coeff_der + + @property + def static_margin(self): + """Pitch-plane static margin (calibers) vs time (lazily evaluated).""" + self._ensure_margins() + return self._static_margin + + @property + def static_margin_yaw(self): + """Yaw-plane static margin (calibers) vs time (lazily evaluated).""" + self._ensure_margins() + return self._static_margin_yaw + + @property + def stability_margin(self): + """Pitch-plane stability margin (calibers) vs Mach and time (lazy).""" + self._ensure_margins() + return self._stability_margin + + @property + def stability_margin_yaw(self): + """Yaw-plane stability margin (calibers) vs Mach and time (lazy).""" + self._ensure_margins() + return self._stability_margin_yaw + def evaluate_center_of_pressure(self): - """Evaluates rocket center of pressure position relative to user defined - rocket reference system. It can be called as many times as needed, as it - will update the center of pressure function every time it is called. The - code will iterate through all aerodynamic surfaces and consider each of - their center of pressure position and derivative of the coefficient of - lift as a function of Mach number. + """Evaluates the rocket's **aerodynamic center** as a function of Mach + number, relative to the user-defined rocket reference system. + + The aerodynamic center is the linearized (small-incidence, + :math:`\\alpha=\\beta=0`) center of pressure: the normal-force-slope- + weighted average of every aerodynamic surface's location. It is the + well-conditioned reference used by the static and stability margins. The + nonlinear center of pressure at a finite angle of attack/sideslip is a + separate, singular quantity (``x_cdm + csys * d * Cm / CN``) that can be + reconstructed from :meth:`aerodynamic_coefficients_full` when needed. + + It is computed independently for the **pitch** plane + (``aerodynamic_center``, from the normal-force/pitch-moment slopes) and + the **yaw** plane (``aerodynamic_center_yaw``, from the + side-force/yaw-moment slopes). For an axisymmetric rocket the two + coincide; when they differ (a non-axisymmetric configuration, only + expressible through ``GenericSurface``), a warning is raised because the + scalar ``static_margin``/``stability_margin`` attributes describe the + pitch plane only. Returns ------- - self.cp_position : Function - Function of Mach number expressing the rocket's center of pressure - position relative to user defined rocket reference system. - See :doc:`Positions and Coordinate Systems ` - for more information. + self.aerodynamic_center : Function + Function of Mach number expressing the rocket's pitch-plane + aerodynamic center position relative to the user-defined rocket + reference system. See :doc:`Positions and Coordinate Systems + ` for more information. """ - # Re-Initialize total lift coefficient derivative and center of pressure position - 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) + lift_coeff_der = aero_surface.lift_coefficient_derivative + cp_z = aero_surface.center_of_pressure_z + # ref_factor corrects force for different reference areas + ref_factor = aero_surface.reference_area / self.area + self._total_lift_coeff_der += ref_factor * lift_coeff_der + self._aerodynamic_center += ( + ref_factor * lift_coeff_der * (position.z - self._csys * cp_z) + ) + + # Yaw plane. + side_coeff_der = aero_surface.side_coefficient_derivative + cp_z_yaw = aero_surface.center_of_pressure_z_yaw + self._total_side_coeff_der += ref_factor * side_coeff_der + self._aerodynamic_center_yaw += ( + ref_factor * side_coeff_der * (position.z - self._csys * cp_z_yaw) ) - # Avoid errors when only 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 + + # One-shot non-axisymmetry advisory. Both plane centers are already built + # here, so detection costs only a Mach sweep -- no extra evaluation and no + # per-surface-add repetition. ``_cp_outdated`` was cleared at the top, so + # reading ``is_axisymmetric`` (which reads the centers) does not recurse. + # Emitted at most once per rocket, when the scalar pitch-plane margins + # first become potentially misleading. + if not self._axisymmetry_warned and not self.is_axisymmetric: + self._axisymmetry_warned = True + max_diff = self._cp_plane_max_difference() + warnings.warn( + "Pitch- and yaw-plane aerodynamic centers differ " + f"(max difference ~{max_diff:.4g} m): the rocket is not " + "axisymmetric. 'aerodynamic_center', 'static_margin' and " + "'stability_margin' describe the PITCH plane; use " + "'aerodynamic_center_yaw', 'static_margin_yaw' and " + "'stability_margin_yaw' for the yaw plane.", + stacklevel=2, + ) + + return self._aerodynamic_center + + def _cp_plane_max_difference(self): + """Largest pitch- vs yaw-plane aerodynamic center difference, in meters. + + The difference is sampled densely across the subsonic, transonic and + supersonic regimes rather than at a few fixed Mach numbers. A + non-axisymmetric configuration (only possible through a ``GenericSurface`` + with non-mirror coefficients) can have its pitch/yaw aerodynamic centers + diverge in any Mach range, and the difference can vanish at isolated Mach + numbers; sparse fixed sampling (e.g. only 0, 0.5, 1) risks a *false + negative* -- silently reporting an asymmetric rocket as axisymmetric, so + the user trusts the pitch-plane-only static margin. The built-in + (Barrowman) surfaces are symmetric by construction, so this returns + exactly 0 for them at every Mach (no false positives). This runs once at + setup, not in the integration loop, so a dense sweep is cheap. + """ + # 0 to 3 in 0.2 steps covers RocketPy's flight regimes (sub/trans/ + # supersonic) with enough resolution that a real asymmetry, which spans a + # Mach *range*, cannot fall entirely between sample points. This only + # runs for rockets that contain a generic surface or individual fin (see + # the by-construction short-circuit in ``is_axisymmetric``). + sample_machs = np.linspace(0.0, 3.0, 16) + return max( + abs( + self.aerodynamic_center.get_value_opt(mach) + - 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``).""" + # Fast path: the built-in nose, tail and fin sets contribute identically + # to the pitch and yaw planes by construction, so a rocket made only of + # surfaces that are axisymmetric-by-construction is axisymmetric without + # evaluating anything. Only a generic surface or an individual fin can + # break it, in which case fall back to the numeric Mach sweep below. + if all( + getattr(surface, "is_axisymmetric", False) + for surface, _ in self.aerodynamic_surfaces + ): + return True + # Tolerance relative to the rocket diameter (caliber-scale). + return self._cp_plane_max_difference() <= 1e-6 * (2 * self.radius) + + @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. + The genuine, force-application center of pressure at a finite incidence + is ``x_cdm + csys * d * Cm / CN`` and can be reconstructed on demand from + :meth:`aerodynamic_coefficients_full`; it is intentionally not exposed as + a method because it is singular at zero normal force (zero incidence). + """ + return self.aerodynamic_center + + def _aerodynamic_forces_and_moments(self, alpha, beta, mach, reynolds=0.0): + """Total body-frame aerodynamic force ``(R1, R2, R3)`` and moment + ``(M1, M2, M3)`` about the center of dry mass, summed over every + aerodynamic surface at a static state (zero rates), plus the + ``stream_speed`` used. + + Computed at unit air density, so the forces equal the dimensionless + coefficients times ``0.5 * stream_speed**2 * reference_area``; dynamic + pressure therefore cancels from any coefficient or center-of-pressure + ratio. The viscosity is chosen so each surface's Reynolds number (built + on its own reference length) is consistent with the requested + rocket-level Reynolds number (built on the diameter): + ``Re_surface = reynolds * reference_length / (2 * radius)``; a + non-positive Reynolds collapses to the vanishing-Reynolds limit. + """ + # Body-frame stream velocity reproducing (alpha, beta). + # ``compute_forces_and_moments`` negates it internally and recovers + # ``alpha = atan2(sv_y, sv_z)`` and ``beta = atan2(sv_x, sv_z)``. + stream_velocity = Vector([-math.tan(beta), -math.tan(alpha), -1.0]) + stream_speed = abs(stream_velocity) + omega = Vector([0, 0, 0]) + density = Function(1.0) + if reynolds > 0: + dynamic_viscosity = Function(stream_speed * 2 * self.radius / reynolds) + else: + dynamic_viscosity = Function(1e30) + + totals = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + for surface, _ in self.aerodynamic_surfaces: + cp = self.surfaces_cp_to_cdm[surface] + forces = surface.compute_forces_and_moments( + stream_velocity, + stream_speed, + mach, + 1.0, + cp, + omega, + density, + dynamic_viscosity, + 0.0, + ) + totals = [acc + value for acc, value in zip(totals, forces)] + return (*totals, stream_speed) + + def aerodynamic_coefficients(self, alpha, beta, mach, reynolds=0.0): + """Total rocket aerodynamic coefficients at a given state, referenced to + the rocket cross-section area and diameter and taken about the center of + dry mass. + + Parameters + ---------- + alpha, beta : float + Angle of attack and sideslip, in radians. + mach : float + Free-stream Mach number. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + + Returns + ------- + dict + ``{"normal_force": C_N, "pitch_moment": C_m}`` -- the total + normal-force and pitch-moment (about the center of dry mass) + coefficient magnitudes. + + Notes + ----- + The rocket's axial (drag) coefficient is **not** included: the geometric + (Barrowman) surfaces carry no drag coefficient, the rocket drag being + supplied separately by ``power_off_drag``/``power_on_drag``. See + :meth:`Rocket.plots.drag_curves`. + """ + r1, r2, _, m1, m2, _, stream_speed = self._aerodynamic_forces_and_moments( + alpha, beta, mach, reynolds + ) + dynamic_pressure_area = 0.5 * stream_speed**2 * self.area + if dynamic_pressure_area == 0: + return {"normal_force": 0.0, "pitch_moment": 0.0} + reference_length = 2 * self.radius + return { + "normal_force": (r1**2 + r2**2) ** 0.5 / dynamic_pressure_area, + "pitch_moment": (m1**2 + m2**2) ** 0.5 + / (dynamic_pressure_area * reference_length), + } + + def aerodynamic_coefficients_full(self, alpha, beta, mach, reynolds=0.0): + """All six signed rocket-level aerodynamic coefficients at a state. + + Aggregates every aerodynamic surface into the vehicle's force and moment + coefficients, referenced to the rocket cross-section area and diameter + and taken about the center of dry mass, in the body aerodynamic frame: + + - ``cL`` (lift), ``cQ`` (side force), ``cD`` (drag); + - ``cm`` (pitch), ``cn`` (yaw), ``cl`` (roll). + + Unlike :meth:`aerodynamic_coefficients` (which returns unsigned + normal-force and pitch-moment magnitudes) these are signed and complete. + The drag coefficient ``cD`` is taken from the vehicle drag curve + (``power_off_drag``), since the geometric surfaces carry no drag + coefficient; this unifies the per-surface lift/moment model with the + separately supplied drag curve into a single coefficient set. + + Parameters + ---------- + alpha, beta : float + Angle of attack and sideslip, in radians. + mach : float + Free-stream Mach number. + reynolds : float, optional + Rocket-level Reynolds number. Default 0. + + Returns + ------- + dict + ``{"cL", "cQ", "cD", "cm", "cn", "cl"}``. + """ + r1, r2, r3, m1, m2, m3, stream_speed = self._aerodynamic_forces_and_moments( + alpha, beta, mach, reynolds + ) + dynamic_pressure_area = 0.5 * stream_speed**2 * self.area + if dynamic_pressure_area == 0: + return {c: 0.0 for c in ("cL", "cQ", "cD", "cm", "cn", "cl")} + reference_length = 2 * self.radius + dynamic_pressure_area_length = dynamic_pressure_area * reference_length + # Body-frame force/moment components map to the aerodynamic-frame + # coefficients (see GenericSurface.compute_forces_and_moments, which + # builds the body force from Vector([side, -lift, -drag])). + return { + "cL": -r2 / dynamic_pressure_area, + "cQ": r1 / dynamic_pressure_area, + "cD": self.power_off_drag_by_mach.get_value_opt(mach), + "cm": m1 / dynamic_pressure_area_length, + "cn": m2 / dynamic_pressure_area_length, + "cl": m3 / dynamic_pressure_area_length, + } def evaluate_surfaces_cp_to_cdm(self): """Calculates the relative position of each aerodynamic surface center @@ -660,12 +1021,20 @@ def evaluate_surfaces_cp_to_cdm(self): """ for surface, position in self.aerodynamic_surfaces: self.__evaluate_single_surface_cp_to_cdm(surface, position) + for effector in self.effectors: + self.__evaluate_single_effector_cp_to_cdm(effector) return self.surfaces_cp_to_cdm def __evaluate_single_surface_cp_to_cdm(self, surface, position): """Calculates the relative position of each aerodynamic surface center of pressure to the rocket's center of dry mass in Body Axes Coordinate System.""" + # Surfaces pinned to the center of dry mass (air brakes added without + # an explicit position) always apply their force with zero moment arm, + # even after add_motor moves the center of dry mass. + if getattr(surface, "_pin_cp_to_cdm", False): + self.surfaces_cp_to_cdm[surface] = Vector([0, 0, 0]) + return # position of the surfaces coordinate system origin in body frame pos_origin = Vector( [ @@ -674,14 +1043,38 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): (position.z - self.center_of_dry_mass_position) * self._csys, ] ) - # position of the center of pressure in body frame + # position of the force application point in body frame. Surfaces that + # carry their center-of-pressure offset in the moment coefficients + # (Barrowman surfaces) apply the force at the origin; surfaces that + # transport the moment geometrically use their center of pressure. + application_point = getattr( + surface, + "force_application_point", + Vector([surface.cpx, surface.cpy, surface.cpz]), + ) pos = ( - surface._rotation_surface_to_body - @ Vector([surface.cpx, surface.cpy, surface.cpz]) - + pos_origin + surface._rotation_surface_to_body @ application_point + pos_origin ) # TODO: this should be recomputed whenever cant angle changes for fin self.surfaces_cp_to_cdm[surface] = pos + def __evaluate_single_effector_cp_to_cdm(self, effector): + """Body-frame position of an effector's application point relative to the + center of dry mass (its moment arm). ``effector.position`` is either an + axial station (float) or a full ``(x, y, z)`` point in the user + coordinate system.""" + position = effector.position + if isinstance(position, (int, float)): + px, py, pz = 0.0, 0.0, float(position) + else: + px, py, pz = position[0], position[1], position[2] + self.effectors_cp_to_cdm[effector] = Vector( + [ + (px - self.cm_eccentricity_x) * self._csys, + (py - self.cm_eccentricity_y), + (pz - self.center_of_dry_mass_position) * self._csys, + ] + ) + def evaluate_stability_margin(self): """Calculates the stability margin of the rocket as a function of mach number and time. @@ -694,19 +1087,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.cp_position.get_value_opt(mach) + - self.aerodynamic_center.get_value_opt(mach) ) / (2 * self.radius) ) * self._csys ) ) - return self.stability_margin + # Yaw-plane stability margin (equal to the pitch plane when axisymmetric) + self._stability_margin_yaw.set_source( + lambda mach, time: ( + ( + ( + self.center_of_mass.get_value_opt(time) + - self.aerodynamic_center_yaw.get_value_opt(mach) + ) + / (2 * self.radius) + ) + * self._csys + ) + ) + return self._stability_margin def evaluate_static_margin(self): """Calculates the static margin of the rocket as a function of time. @@ -719,24 +1125,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 + ) + + # 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 + return self._static_margin def evaluate_dry_inertias(self): """Calculates and returns the rocket's dry inertias relative to @@ -1066,10 +1490,10 @@ def add_motor(self, motor, position): # pylint: disable=too-many-statements self.evaluate_inertias() self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - self.evaluate_center_of_pressure() self.evaluate_surfaces_cp_to_cdm() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # The motor changes the center of mass (and thus the margins) but not the + # surface-only aerodynamic center; flag only the margins for lazy rebuild. + self._margin_outdated = True self.evaluate_com_to_cdm_function() self.evaluate_nozzle_gyration_tensor() @@ -1148,9 +1572,226 @@ def add_surfaces(self, surfaces, positions): else: self.__add_single_surface(surfaces, positions) - self.evaluate_center_of_pressure() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # Adding a surface changes both the aerodynamic center and the margins; + # flag them for lazy rebuild on next access (see the properties). The + # non-axisymmetry advisory is emitted (once) from + # ``evaluate_center_of_pressure`` on that first rebuild, rather than + # eagerly here on every add. + self._cp_outdated = True + self._margin_outdated = True + + 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` / :meth:`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_controllable_surface( + self, + surface, + position, + controller_function, + sampling_rate, + context=None, + controller_name=None, + controlled_objects_name=None, + needs=None, + enabled=True, + disable_on=None, + enable_on=None, + ): + """Add controllable surface(s) to the rocket and register a + :class:`SurfaceController` driving them. + + The surface(s) join ``aerodynamic_surfaces`` like any other surface + (their forces and moments are summed in the equations of motion), and + the controller executes at ``sampling_rate`` during flight, applying + control actions via ``surface.set_control``. The control state of each + surface is automatically recorded in the controller's + ``control_history`` and reset between flights. + + Parameters + ---------- + surface : ControllableGenericSurface or list + Controllable surface(s) to add. + position : int, float, tuple or list + Position(s) of the surface(s) in the rocket's user coordinate + system; same forms as :meth:`add_surfaces` (a list of positions + for a list of surfaces). + controller_function : callable + Control logic with signature ``controller_function(**kwargs)``; + see :class:`Controller` for the available keyword arguments. + sampling_rate : float + Rate in hertz at which the controller executes. + context : dict, optional + Initial persistent controller state. + controller_name : str, optional + Controller name; defaults to ``" Controller"``. + controlled_objects_name : str or list of str, optional + Friendly name(s) under which the surface(s) are exposed in the + controller function kwargs. + needs : list or frozenset of str or None, optional + Expensive simulation values the controller function accesses; + see :class:`Controller`. + enabled : bool, optional + Initial enabled state of the controller. Defaults to ``True``. + disable_on, enable_on : str or int or float or callable, optional + Automatic disable/enable conditions; see :class:`Controller`. + + Returns + ------- + SurfaceController + The controller created and registered on the rocket. + """ + SurfaceController._validate_surfaces(surface) + self.add_surfaces(surface, position) + if controller_name is None: + first = surface[0] if isinstance(surface, (list, tuple)) else surface + controller_name = f"{getattr(first, 'name', 'Surface')} Controller" + controller = SurfaceController( + controller_function, + surface, + sampling_rate, + context=context, + name=controller_name, + controlled_objects_name=controlled_objects_name, + needs=needs, + enabled=enabled, + disable_on=disable_on, + enable_on=enable_on, + ) + self._add_controllers(controller) + return controller + + def add_effector( + self, + effector, + position=None, + controller_function=None, + sampling_rate=None, + controlled_objects_name=None, + needs=None, + enabled=True, + disable_on=None, + enable_on=None, + context=None, + controller_name=None, + ): + """Register a control :class:`rocketpy.Effector` on the rocket. + + The effector injects a body-frame force and/or moment **directly** into + the equations of motion (summed alongside the aerodynamic surfaces), + rather than producing force through the aerodynamic model. Use it for + non-aerodynamic control such as reaction-control thrusters or reaction + wheels. Unlike aerodynamic surfaces, effectors are **not** added to + ``aerodynamic_surfaces`` and therefore do not affect the center of + pressure or the stability margins. + + If ``controller_function`` is given, a :class:`rocketpy.Controller` + driving the effector is created and registered (closed-loop control); the + controller sets the effector's control state each step. Otherwise the + effector runs open-loop at its (fixed) control state. + + Parameters + ---------- + effector : Effector + The effector to add (e.g. a :class:`rocketpy.GenericEffector`). + position : float, tuple or None, optional + Application station (axial float) or ``(x, y, z)`` point in the user + coordinate system, setting the effector's moment arm. If ``None`` + (default), the effector's existing ``position`` is used. + controller_function : callable, optional + ``controller_function(**kwargs)`` control law driving the effector + (see :class:`Controller`). If ``None`` (default), no controller is + created and the effector is open-loop. + sampling_rate : float, optional + Controller execution rate in hertz (required when + ``controller_function`` is given). + controlled_objects_name, needs, enabled, disable_on, enable_on, context, + controller_name : + Forwarded to the created :class:`Controller`; see its documentation. + + Returns + ------- + Controller or Effector + The created controller when ``controller_function`` is given, + otherwise the effector itself. + """ + if position is not None: + effector.position = position + self.effectors.append(effector) + self.__evaluate_single_effector_cp_to_cdm(effector) + + if controller_function is None: + return effector + + controller = Controller( + controller_function, + effector, + sampling_rate, + context=context, + name=controller_name or f"{effector.name} Controller", + controlled_objects_name=controlled_objects_name, + enabled=enabled, + disable_on=disable_on, + enable_on=enable_on, + needs=needs, + ) + self._add_controllers(controller) + return controller def _add_controllers(self, controllers): """Adds a controller to the rocket. @@ -1742,6 +2383,7 @@ def add_air_brakes( name="AirBrakes", controller_name="AirBrakes Controller", controller_needs=None, + position=None, ): """Creates a new air brakes system, storing its parameters such as drag coefficient curve, controller function, sampling rate, and @@ -1769,12 +2411,13 @@ def add_air_brakes( - If a Function, it must take two parameters: deployment level and Mach number, and return the drag coefficient. - .. note:: For ``override_rocket_drag = False``, at - deployment level 0, the drag coefficient is assumed to be 0, - independent of the input drag coefficient curve. This means that - the simulation always considers that at a deployment level of 0, - the air brakes are completely retracted and do not contribute to - the drag of the rocket. + .. note:: At deployment level 0 the drag coefficient is assumed + to be 0, independent of the input drag coefficient curve. This + means that the simulation always considers that at a deployment + level of 0, the air brakes are completely retracted and do not + contribute to the drag of the rocket (and, for + ``override_rocket_drag = True``, the rocket body drag applies + normally while the brakes are retracted). controller_function : callable Function that executes the control logic, with signature @@ -1795,7 +2438,7 @@ def add_air_brakes( ``height_agl`` (float, m), ``event`` (:class:`Event` wrapping this controller), ``sampling_rate`` (float, Hz), - ``controller`` (this :class:`_Controller` instance), + ``controller`` (this :class:`Controller` instance), ``controlled_objects`` (same as ``air_brakes``), ``air_brakes`` (:class:`AirBrakes`). The following keys are only injected when declared via @@ -1851,13 +2494,22 @@ def add_air_brakes( ``'state_dot'``, ``'pressure'``, ``'state_history'``. ``None`` (default) assumes no needs; pass an explicit list if your controller accesses any of the keys above. + position : int, float, optional + Axial position of the air brakes station in the rocket's user + coordinate system (same convention as :meth:`add_surfaces`). When + given, the air-brake force is applied at that station, producing + the corresponding moments at nonzero angle of attack. If ``None`` + (default), the force application point is pinned to the center of + dry mass, giving zero moment arm and matching the historical + drag-only behavior. Returns ------- air_brakes : AirBrakes AirBrakes object created. - controller : Controller - Controller object created. + controller : AirBrakesController + Controller object created. Only returned if ``return_controller`` + is ``True``. """ reference_area = reference_area if reference_area is not None else self.area air_brakes = AirBrakes( @@ -1938,6 +2590,8 @@ def add_air_brakes( def controller_wrapper(**kwargs): if accepts_var_kwargs: + # Hybrid signatures like ``f(time, **kwargs)``: every value is + # forwarded by keyword, so positional params bind by name. return orig_controller(**kwargs) # Legacy positional signature expected. Build positional args in @@ -1948,7 +2602,7 @@ def controller_wrapper(**kwargs): state = kwargs.get("state") state_history = kwargs.get("state_history") observed_variables = controller_context.get("observed_variables", []) - interactive_objects = kwargs.get("interactive_objects", air_brakes) + interactive_objects = air_brakes sensors = kwargs.get("sensors") environment = kwargs.get("environment") @@ -1970,23 +2624,43 @@ def controller_wrapper(**kwargs): return orig_controller(*legacy_args) - # TODO: should this be in the airbrakes object instead? - _controller = _Controller( - controller_function=controller_wrapper, - controlled_objects=air_brakes, - controlled_objects_name="air_brakes", + # Pure ``**kwargs`` functions need no compatibility wrapper. + pass_through = ( + accepts_var_kwargs + and positional_parameter_count == 0 + and not accepts_var_args + ) + _controller = AirBrakesController( + controller_function=( + orig_controller if pass_through else controller_wrapper + ), + air_brakes=air_brakes, sampling_rate=sampling_rate, context=controller_context, name=controller_name, - controller_needs=controller_needs, + needs=controller_needs, ) - self.air_brakes.append(air_brakes) - self._add_controllers(_controller) + self._attach_air_brakes(air_brakes, _controller, position=position) if return_controller: return air_brakes, _controller else: return air_brakes + def _attach_air_brakes(self, air_brakes, controller=None, position=None): + """Wire an AirBrakes surface (and optionally its controller) into the + rocket: the surface joins ``aerodynamic_surfaces`` so its drag is + summed in the standard surface loop, and is also appended to the + ``air_brakes`` list. Without an explicit ``position``, the force + application point is pinned to the center of dry mass (zero moment + arm), reproducing the historical drag-only behavior.""" + if position is None: + air_brakes._pin_cp_to_cdm = True + position = self.center_of_dry_mass_position + self.add_surfaces(air_brakes, position) + self.air_brakes.append(air_brakes) + if controller is not None: + self._add_controllers(controller) + def set_rail_buttons( self, upper_button_position, @@ -2220,17 +2894,19 @@ def to_dict(self, **kwargs): "coordinate_system_orientation": self.coordinate_system_orientation, "motor": self.motor, "motor_position": self.motor_position, + # Air brakes are serialized once, inside aerodynamic_surfaces + # (with their position); the air_brakes list is rebuilt on load. "aerodynamic_surfaces": self.aerodynamic_surfaces, "rail_buttons": self.rail_buttons, "parachutes": self.parachutes, - "air_brakes": self.air_brakes, "_controllers": self._controllers, "sensors": self.sensors, + "effectors": self.effectors, } 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 +2919,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 +2971,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 @@ -2345,294 +3023,52 @@ def from_dict(cls, data): for sensor, position in data["sensors"]: rocket.add_sensor(sensor, position) - for air_brake in data["air_brakes"]: - rocket.air_brakes.append(air_brake) + # Air brakes serialized inside aerodynamic_surfaces (new format) were + # re-added by add_surfaces above; rebuild the air_brakes list from + # them. Legacy files carry air brakes only under the "air_brakes" key + # and must still be wired into the surface loop (pinned to the CDM, + # which is how they historically behaved). + rocket.air_brakes = [ + surface + for surface, _ in rocket.aerodynamic_surfaces + if isinstance(surface, AirBrakes) + ] + for air_brake in data.get("air_brakes", []): + if air_brake not in rocket.air_brakes: + rocket._attach_air_brakes(air_brake) + + # Effectors are re-registered open-loop; their driving controller (if + # any) is restored from "_controllers" below and rewired by name. + for effector in data.get("effectors", []): + rocket.add_effector(effector) + + # Controllers reference their controlled objects by name; match them + # against the freshly decoded surfaces, air brakes and effectors. + candidates = {} + for surface, _ in rocket.aerodynamic_surfaces: + candidates.setdefault(getattr(surface, "name", None), surface) + for air_brake in rocket.air_brakes: + candidates.setdefault(air_brake.name, air_brake) + for effector in rocket.effectors: + candidates.setdefault(effector.name, effector) for controller in data["_controllers"]: - interactive_objects_hash = getattr(controller, "_interactive_objects_hash") - if interactive_objects_hash is not None: - is_iterable = isinstance(interactive_objects_hash, Iterable) - if not is_iterable: - interactive_objects_hash = [interactive_objects_hash] - for hash_ in interactive_objects_hash: - if (hashed_obj := find_obj_from_hash(data, hash_)) is not None: - if not is_iterable: - controller.interactive_objects = hashed_obj - else: - controller.interactive_objects.append(hashed_obj) - else: - warnings.warn( - "Could not find controller interactive objects." - "Deserialization will proceed, results may not be accurate." - ) + references = getattr(controller, "_controlled_objects_ref", None) or [] + objects = [] + for reference in references: + if reference in candidates: + objects.append(candidates[reference]) + else: + warnings.warn( + f"Could not find controlled object '{reference}' while " + f"loading rocket; controller '{controller.name}' will " + "be attached without it." + ) + if objects: + controller.bind_controlled_objects( + objects[0] if len(objects) == 1 else objects, + controller.controlled_objects_name, + ) 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..6eaab590c 100644 --- a/rocketpy/simulation/events/event.py +++ b/rocketpy/simulation/events/event.py @@ -99,9 +99,8 @@ def __init__( # pylint: disable=too-many-arguments Dictionary of persistent, mutable per-event state, exposed through ``kwargs["event"].context`` inside the trigger and callback. Useful for counters, thresholds, and data shared between trigger and - callback. Each key is also unpacked into the ``**kwargs`` passed to - the trigger and callback. Defaults to an empty dict. Note that - ``context`` is not persisted to output logs or files. + callback. Defaults to an empty dict. Note that ``context`` is not + persisted to output logs or files. disable_on : str or int or float or callable, optional Condition that automatically disables the event. May be a string preset (``"apogee"`` or ``"burnout"``), a simulation time in seconds diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index baff48a38..7427a054d 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -700,9 +700,8 @@ def __init_events(self): parachute._reset_signals() # reset parachute pressure signals self.events.append(parachute.event) - # Controller events - for controller in self._controllers: - self.events.append(controller.event) + # Controller events (a Controller is an Event) + self.events.extend(self._controllers) # User-defined events are appended last self.events.extend(user_events) @@ -735,9 +734,9 @@ def __init_eventful_objects(self): self.sensors = self.rocket.sensors.get_components() self.sensors_by_name = self.rocket.sensors_by_name - # reset controllable object to initial state (only airbrakes for now) - for air_brakes in self.rocket.air_brakes: - air_brakes._reset() + # Controlled objects (air brakes, controllable surfaces, ...) are + # restored to their initial control state by ``Controller.reset()``, + # invoked for every event in ``__init_events``. self.sensor_data = {} for sensor in self.sensors: @@ -2305,24 +2304,204 @@ def static_margin(self): @funcify_method("Time (s)", "Stability Margin (c)", "linear", "zero") def stability_margin(self): - """Stability margin of the rocket along the flight, it considers the - variation of the center of pressure position according to the mach - number, as well as the variation of the center of gravity position - according to the propellant mass evolution. + """Linear stability margin along the flight, in calibers. - Parameters - ---------- - None + This is the classical (aerodynamic-center) margin: it evaluates the + rocket's linearized stability margin + (:meth:`Rocket.stability_margin`) at the realized flight Mach and time at + each instant, capturing the Mach variation of the aerodynamic center + together with the center-of-mass shift as propellant burns. It is + well-conditioned and never spikes. Returns ------- stability : rocketpy.Function - Stability margin as a rocketpy.Function of time. The stability margin - is defined as the distance between the center of pressure and the - center of gravity, divided by the rocket diameter. + Stability margin in calibers as a function of time. A positive + margin (aerodynamic center behind the center of mass) is the classic + passive-stability condition. """ return [(t, self.rocket.stability_margin(m, t)) for t, m in self.mach_number] + @funcify_method("Time (s)", "Stability Margin - Yaw (c)", "linear", "zero") + def stability_margin_yaw(self): + """Linear yaw-plane stability margin along the flight, in calibers. + + Yaw-plane counterpart of :meth:`stability_margin`, using the rocket's + yaw-plane aerodynamic center (:meth:`Rocket.stability_margin_yaw`). + Equals :meth:`stability_margin` for an axisymmetric rocket; for a + non-axisymmetric rocket (e.g. single-plane canards) it differs, since the + pitch and yaw aerodynamic centers no longer coincide. + + Returns + ------- + stability : rocketpy.Function + Yaw-plane stability margin in calibers as a function of time. + """ + return [ + (t, self.rocket.stability_margin_yaw(m, t)) for t, m in self.mach_number + ] + + # Dynamic stability + def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): + """Lateral moment of inertia about the instantaneous center of mass, as + an array over ``self.time``. Uses the reduced-mass formulation of the + equations of motion: ``I_L = I_dry + I_motor(t) + mu(t) b^2`` with + ``mu`` the dry/propellant reduced mass and ``b`` the (initial) + dry-mass-to-propellant distance.""" + dry_mass = self.rocket.dry_mass + b = ( + -( + self.rocket.center_of_propellant_position.get_value_opt(0) + - self.rocket.center_of_dry_mass_position + ) + * self.rocket._csys + ) + inertia = np.empty(len(self.time)) + for i, t in enumerate(self.time): + propellant_mass = self.rocket.motor.propellant_mass.get_value_opt(t) + total = propellant_mass + dry_mass + mu = (propellant_mass * dry_mass / total) if total > 0 else 0.0 + inertia[i] = ( + dry_lateral_inertia + motor_lateral_inertia.get_value_opt(t) + mu * b**2 + ) + return inertia + + def _dynamic_stability(self, lift_slope, stability_margin, lateral_inertia): + """Linearized oscillator coefficients for one plane, as arrays over + ``self.time``: corrective moment coefficient ``C1`` (restoring moment per + radian), damping moment coefficient ``C2`` (aerodynamic + jet), undamped + natural frequency ``omega_n`` and damping ratio ``zeta``. + + ``lift_slope`` is the rocket's total normal-force-curve slope for the + plane (``total_lift_coeff_der`` for pitch, ``total_side_coeff_der`` for + yaw); ``stability_margin`` is the matching linear margin + ``Function(mach, time)``; ``lateral_inertia`` is the array from + :meth:`_lateral_inertia`. + """ + area = self.rocket.area + diameter = 2 * self.rocket.radius + csys = self.rocket._csys + nozzle_position = self.rocket.nozzle_position + mass_flow_rate = self.rocket.motor.total_mass_flow_rate + + corrective = np.empty(len(self.time)) + damping = np.empty(len(self.time)) + for i, t in enumerate(self.time): + mach = self.mach_number.get_value_opt(t) + dynamic_pressure = self.dynamic_pressure.get_value_opt(t) + speed = self.speed.get_value_opt(t) + density = self.density.get_value_opt(t) + center_of_mass = self.rocket.center_of_mass.get_value_opt(t) + + # Corrective moment per radian: q A C_Nalpha (x_cm - x_ac). + margin = stability_margin.get_value_opt(mach, t) # calibers + corrective[i] = ( + dynamic_pressure + * area + * lift_slope.get_value_opt(mach) + * margin + * diameter + ) + + # Aerodynamic damping: 0.5 rho V A sum_i (A_i/A) C_Nalpha_i arm_i^2. + damping_aero = 0.0 + for surface, position in self.rocket.aerodynamic_surfaces: + slope = surface.lift_coefficient_derivative.get_value_opt(mach) + cp_position = ( + position.z - csys * surface.center_of_pressure_z.get_value_opt(mach) + ) + arm = cp_position - center_of_mass + ref_factor = surface.reference_area / area + damping_aero += ref_factor * slope * arm**2 + damping_aero *= 0.5 * density * speed * area + + # Jet (propulsive) damping: mdot (x_nozzle - x_cm)^2. + damping_jet = ( + abs(mass_flow_rate.get_value_opt(t)) + * (nozzle_position - center_of_mass) ** 2 + ) + damping[i] = damping_aero + damping_jet + + positive_corrective = np.clip(corrective, 0.0, None) + with np.errstate(divide="ignore", invalid="ignore"): + natural_frequency = np.sqrt(positive_corrective / lateral_inertia) + denominator = 2.0 * np.sqrt(positive_corrective * lateral_inertia) + damping_ratio = np.divide( + damping, + denominator, + out=np.zeros_like(damping), + where=denominator > 0, + ) + return corrective, damping, natural_frequency, damping_ratio + + @funcify_method("Time (s)", "Corrective Moment Coefficient (N m/rad)", "linear") + def corrective_moment_coefficient(self): + """Pitch-plane corrective (restoring) moment coefficient ``C1`` as a + function of time -- the aerodynamic restoring moment per radian of angle + of attack. Positive for a statically stable rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + corrective, _, _, _ = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, corrective)) + + @funcify_method("Time (s)", "Damping Moment Coefficient (N m s/rad)", "linear") + def damping_moment_coefficient(self): + """Pitch-plane damping moment coefficient ``C2`` as a function of time -- + the moment opposing the pitch rate, summing aerodynamic damping (from + every surface) and propulsive (jet) damping.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, damping, _, _ = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, damping)) + + @funcify_method("Time (s)", "Pitch Natural Frequency (rad/s)", "linear") + def pitch_natural_frequency(self): + """Undamped natural frequency of the pitch oscillation, + ``omega_n = sqrt(C1 / I_L)``, as a function of time (rad/s).""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, _, natural_frequency, _ = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, natural_frequency)) + + @funcify_method("Time (s)", "Pitch Damping Ratio", "linear") + def pitch_damping_ratio(self): + """Damping ratio of the pitch oscillation, + ``zeta = C2 / (2 sqrt(C1 I_L))``, as a function of time. ``zeta < 1`` is + underdamped (oscillatory), ``zeta > 1`` overdamped.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, _, _, damping_ratio = self._dynamic_stability( + self.rocket.total_lift_coeff_der, self.rocket.stability_margin, inertia + ) + return np.column_stack((self.time, damping_ratio)) + + @funcify_method("Time (s)", "Yaw Natural Frequency (rad/s)", "linear") + def yaw_natural_frequency(self): + """Undamped natural frequency of the yaw oscillation as a function of + time (rad/s). Equals :meth:`pitch_natural_frequency` for an axisymmetric + rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_22, self.rocket.motor.I_22) + _, _, natural_frequency, _ = self._dynamic_stability( + self.rocket.total_side_coeff_der, + self.rocket.stability_margin_yaw, + inertia, + ) + return np.column_stack((self.time, natural_frequency)) + + @funcify_method("Time (s)", "Yaw Damping Ratio", "linear") + def yaw_damping_ratio(self): + """Damping ratio of the yaw oscillation as a function of time. Equals + :meth:`pitch_damping_ratio` for an axisymmetric rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_22, self.rocket.motor.I_22) + _, _, _, damping_ratio = self._dynamic_stability( + self.rocket.total_side_coeff_der, + self.rocket.stability_margin_yaw, + inertia, + ) + return np.column_stack((self.time, damping_ratio)) + # Rail Button Forces @cached_property @@ -2751,6 +2930,14 @@ def from_dict(cls, data): simulation_mode=data.get("simulation_mode", "6DOF"), ) + @property + def controllers(self): + """List of controllers active in this flight. Each controller exposes + its execution returns via ``log`` and the recorded control state of + its controlled objects via ``control_history`` / + ``recorded_schedule``.""" + return self._controllers + # These should be deprecated on v1.13 @deprecated( reason="Controller observed variables are no longer supported.", diff --git a/rocketpy/simulation/helpers/event_calling.py b/rocketpy/simulation/helpers/event_calling.py index 2af5abc71..b6ba42bff 100644 --- a/rocketpy/simulation/helpers/event_calling.py +++ b/rocketpy/simulation/helpers/event_calling.py @@ -2,10 +2,19 @@ def compute_needs_union(events): - """Return the union of ``Event.needs`` for all enabled events.""" + """Return the union of ``Event.needs`` across every event that may run on + this node. + + A permanently-disabled event (``enabled`` is ``False`` with no ``enable_on`` + gate) can never fire, so its needs are skipped. But a disabled event that + carries an ``enable_on`` gate may enable **and** run its callback on this + very node (see :meth:`Event.__call__`), so its declared needs must be + included -- otherwise the expensive kwargs it requested (``pressure``, + ``state_dot``, ``state_history``) would be missing on the enabling node. + """ result = frozenset() for event in events: - if not event.enabled: + if not event.enabled and event.enable_on is None: continue result = result | event.needs return result diff --git a/rocketpy/simulation/helpers/flight_derivatives.py b/rocketpy/simulation/helpers/flight_derivatives.py index 1426e2cd3..bc43d7ac7 100644 --- a/rocketpy/simulation/helpers/flight_derivatives.py +++ b/rocketpy/simulation/helpers/flight_derivatives.py @@ -47,6 +47,80 @@ 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 +): + """Rocket body axial aerodynamic (drag) force. + + Selects the power-on/power-off drag curve based on the motor burn state. + Air brakes are aerodynamic surfaces summed in the standard surface loop; + here they only matter through ``override_rocket_drag``: while such air + brakes are deployed, the body drag is suppressed entirely and the + air-brake surface carries the whole vehicle drag instead. + + 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 + for air_brakes in rocket.air_brakes: + if air_brakes.override_rocket_drag and air_brakes.deployment_level > 0: + return 0.0 + + 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] + ) + return -0.5 * rho * stream_speed**2 * rocket.area * drag_coefficient + + +def _apply_effectors(flight, t, velocity_body, omega, mach, R1, R2, R3, M1, M2, M3): + """Sum every control effector's body-frame force and moment (about the center + of dry mass) into the running force/moment totals. + + Effectors inject force/moment directly (non-aerodynamically); see + :class:`rocketpy.Effector`. Returns the updated + ``(R1, R2, R3, M1, M2, M3)``. Callers should guard with + ``if flight.rocket.effectors:`` to skip the overhead when none are present. + """ + for effector in flight.rocket.effectors: + force, moment = effector.evaluate( + flight.rocket.effectors_cp_to_cdm[effector], + t, + velocity_body, + omega, + mach, + flight.env, + ) + R1 += force.x + R2 += force.y + R3 += force.z + M1 += moment.x + M2 += moment.y + M3 += moment.z + return R1, R2, R3, M1, M2, M3 + + def udot_rail1(flight, t, u, post_processing=False): """Compute the 1-DOF rail-flight state derivative. @@ -282,43 +356,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 @@ -361,6 +409,21 @@ def u_dot(flight, t, u, post_processing=False): M1 += M M2 += N M3 += L + # Control effectors: direct body-frame force/moment (non-aerodynamic) + if flight.rocket.effectors: + R1, R2, R3, M1, M2, M3 = _apply_effectors( + flight, + t, + velocity_in_body_frame, + w, + free_stream_mach, + R1, + R2, + R3, + M1, + M2, + M3, + ) # Off center moment M3 += flight.rocket.cp_eccentricity_x * R2 - flight.rocket.cp_eccentricity_y * R1 @@ -549,31 +612,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 @@ -606,6 +657,13 @@ def u_dot_generalized_3dof(flight, t, u, post_processing=False): R2 += fy R3 += fz + # Control effectors contribute their body-frame FORCE only in 3-DOF (there is + # no rotational state, so moment-only effectors are inert here). + if flight.rocket.effectors: + R1, R2, R3, _, _, _ = _apply_effectors( + flight, t, vb_body, w, mach, R1, R2, R3, 0, 0, 0 + ) + # Thrust and weight # Calculate net thrust including pressure thrust correction if motor is burning if flight.rocket.motor.burn_start_time < t < flight.rocket.motor.burn_out_time: @@ -806,43 +864,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 @@ -879,6 +913,22 @@ def u_dot_generalized(flight, t, u, post_processing=False): M2 += N M3 += L + # Control effectors: direct body-frame force/moment (non-aerodynamic) + if flight.rocket.effectors: + R1, R2, R3, M1, M2, M3 = _apply_effectors( + flight, + t, + velocity_in_body_frame, + w, + free_stream_mach, + R1, + R2, + R3, + M1, + M2, + M3, + ) + # Off center moment M1 += ( flight.rocket.cp_eccentricity_y * R3 diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index d1d17ae04..a9d0bdc5d 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -1,9 +1,10 @@ """Defines the StochasticRocket class.""" import warnings +from copy import deepcopy from random import choice -from rocketpy.control import _Controller +from rocketpy.control import AirBrakesController from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors.empty_motor import EmptyMotor from rocketpy.motors.motor import GenericMotor, Motor @@ -393,15 +394,21 @@ def set_rail_buttons( rail_buttons, self._validate_position(rail_buttons, lower_button_position) ) - def add_air_brakes(self, air_brakes, controller): + def add_air_brakes(self, air_brakes, controller, position=None): """Adds an air brake to the stochastic rocket. Parameters ---------- air_brakes : StochasticAirBrakes or Airbrakes The air brake to be added to the stochastic rocket. - controller : _Controller - Deterministic air brake controller. + controller : Controller + Deterministic air brake controller. A fresh controller of the + same class is rebuilt for every sampled rocket, bound to that + sample's air brakes with a deep copy of the context. + position : int, float, optional + Axial position of the air brakes station, as in + ``Rocket.add_air_brakes``. If ``None`` (default), the air-brake + force is pinned to the center of dry mass (zero moment arm). """ if not isinstance(air_brakes, (AirBrakes, StochasticAirBrakes)): raise TypeError( @@ -412,6 +419,7 @@ def add_air_brakes(self, air_brakes, controller): self.air_brakes.append(air_brakes) self.air_brake_controller = controller + self._air_brakes_position = position def add_cp_eccentricity(self, x=None, y=None): """Moves line of action of aerodynamic forces to simulate an @@ -773,17 +781,21 @@ def create_object(self): for air_brake in self.air_brakes: air_brake = self._create_air_brake(air_brake) base_controller = self.air_brake_controller - _controller = _Controller( + _controller = AirBrakesController( controller_function=base_controller.controller_function, - controlled_objects=air_brake, - controlled_objects_name=base_controller.controlled_objects_name, + air_brakes=air_brake, sampling_rate=base_controller.sampling_rate, - context=base_controller.context.copy(), + # Deep copy so nested mutables (e.g. observed-variable lists) + # are not shared across Monte Carlo samples. + context=deepcopy(base_controller.context), name=base_controller.name, - controller_needs=base_controller.controller_needs, + needs=base_controller.needs, + ) + rocket._attach_air_brakes( + air_brake, + _controller, + position=getattr(self, "_air_brakes_position", None), ) - rocket.air_brakes.append(air_brake) - rocket._add_controllers(_controller) for component_rail_buttons in self.rail_buttons: ( diff --git a/tests/unit/control/test_controller.py b/tests/unit/control/test_controller.py new file mode 100644 index 000000000..491f782a8 --- /dev/null +++ b/tests/unit/control/test_controller.py @@ -0,0 +1,297 @@ +"""Unit tests for the public Controller class: event integration, callback +kwargs, automatic control-state tracking, and the reset lifecycle.""" + +import pytest + +from rocketpy import ControllableGenericSurface, ControlledObject, Controller +from rocketpy.simulation.events.event import Event + +STATE = [0.0] * 13 + + +def make_surface(name="canard", controls=("deflection",)): + return ControllableGenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={}, + name=name, + controls=controls, + ) + + +def make_controller(surface=None, **kwargs): + surface = surface if surface is not None else make_surface() + + def controller_function(**kwargs): + return None + + defaults = { + "controller_function": controller_function, + "controlled_objects": surface, + "sampling_rate": 10, + } + defaults.update(kwargs) + return Controller(**defaults), surface + + +class TestControllerConstruction: + def test_controller_is_an_event(self): + controller, _ = make_controller() + assert isinstance(controller, Event) + assert controller.changes_dynamics is True + assert controller.priority == 3 + assert controller.trigger is None + assert controller.trigger_only_once is False + + def test_controllable_surface_satisfies_controlled_object_protocol(self): + assert isinstance(make_surface(), ControlledObject) + + def test_positional_controller_function_rejected(self): + with pytest.raises(ValueError, match="keyword arguments only"): + Controller( + lambda time, state: None, + controlled_objects=make_surface(), + sampling_rate=10, + ) + + def test_var_positional_controller_function_rejected(self): + def controller_function(*args, **kwargs): + return None + + with pytest.raises(ValueError, match="keyword arguments only"): + Controller( + controller_function, + controlled_objects=make_surface(), + sampling_rate=10, + ) + + def test_non_callable_controller_function_rejected(self): + with pytest.raises(ValueError, match="callable"): + Controller( + "not a function", controlled_objects=make_surface(), sampling_rate=10 + ) + + def test_invalid_needs_key_rejected(self): + with pytest.raises(ValueError, match="Unknown needs keys"): + make_controller(needs=["not_a_need"]) + + def test_reserved_controlled_objects_name_rejected(self): + with pytest.raises(ValueError, match="reserved"): + make_controller(controlled_objects_name="time") + + def test_mismatched_controlled_objects_name_length_rejected(self): + surfaces = [make_surface("a"), make_surface("b")] + with pytest.raises(ValueError, match="match number"): + Controller( + lambda **kwargs: None, + controlled_objects=surfaces, + sampling_rate=10, + controlled_objects_name=["only_one"], + ) + + +class TestControllerCallback: + def test_callback_kwargs_injection(self): + received = {} + + def controller_function(**kwargs): + received.update(kwargs) + + surface = make_surface() + controller = Controller( + controller_function, + controlled_objects=surface, + sampling_rate=10, + controlled_objects_name="canard_surface", + ) + controller(time=0.5, state=STATE) + + assert received["controller"] is controller + assert received["event"] is controller + assert received["controlled_objects"] is surface + assert received["canard_surface"] is surface + assert received["time"] == 0.5 + assert received["sampling_rate"] == 10 + + def test_return_value_appended_to_log(self): + def controller_function(**kwargs): + return {"time": kwargs["time"]} + + controller = Controller( + controller_function, controlled_objects=make_surface(), sampling_rate=10 + ) + controller(time=0.1, state=STATE) + controller(time=0.2, state=STATE) + + assert controller.log == [{"time": 0.1}, {"time": 0.2}] + assert controller.log is controller.callback_log + assert controller.return_log is controller.log + + +class TestControlStateTracking: + def test_control_history_records_every_execution(self): + def controller_function(**kwargs): + kwargs["controlled_objects"].set_control("deflection", 0.1 * kwargs["time"]) + + controller, _ = make_controller(controller_function=controller_function) + controller(time=1.0, state=STATE) + controller(time=2.0, state=STATE) + + history = controller.control_history["canard"]["deflection"] + assert history == [ + (1.0, pytest.approx(0.1)), + (2.0, pytest.approx(0.2)), + ] + + def test_history_recorded_even_when_function_does_not_actuate(self): + controller, _ = make_controller() + controller(time=1.0, state=STATE) + assert controller.control_history["canard"]["deflection"] == [(1.0, 0.0)] + + def test_friendly_name_used_as_tracking_key(self): + controller, _ = make_controller(controlled_objects_name="my_canard") + assert list(controller.control_history) == ["my_canard"] + + def test_multiple_objects_tracked_with_deduplicated_names(self): + surfaces = [make_surface("fin"), make_surface("fin")] + controller = Controller( + lambda **kwargs: None, controlled_objects=surfaces, sampling_rate=10 + ) + assert list(controller.control_history) == ["fin", "fin_2"] + + def test_untrackable_objects_are_ignored(self): + controller = Controller( + lambda **kwargs: None, controlled_objects=object(), sampling_rate=10 + ) + assert controller.control_history == {} + controller(time=1.0, state=STATE) # must not raise + + def test_recorded_schedule_interpolates_history(self): + def controller_function(**kwargs): + kwargs["controlled_objects"].set_control("deflection", kwargs["time"]) + + controller, _ = make_controller(controller_function=controller_function) + controller(time=1.0, state=STATE) + controller(time=2.0, state=STATE) + + schedule = controller.recorded_schedule["canard"]["deflection"] + assert schedule(1.0) == pytest.approx(1.0) + assert schedule(1.5) == pytest.approx(1.5) + # constant extrapolation outside the recorded window + assert schedule(5.0) == pytest.approx(2.0) + assert schedule(0.0) == pytest.approx(1.0) + + def test_recorded_schedule_single_sample_is_constant(self): + controller, _ = make_controller() + controller(time=1.0, state=STATE) + schedule = controller.recorded_schedule["canard"]["deflection"] + assert schedule(10.0) == pytest.approx(0.0) + + def test_recorded_schedule_empty_when_no_history(self): + controller, _ = make_controller() + assert controller.recorded_schedule == {} + + +class TestResetLifecycle: + def test_reset_restores_context_snapshot(self): + def controller_function(**kwargs): + kwargs["controller"].context["observed"].append(kwargs["time"]) + + controller, _ = make_controller( + controller_function=controller_function, context={"observed": []} + ) + controller(time=1.0, state=STATE) + assert controller.context == {"observed": [1.0]} + + controller.reset() + assert controller.context == {"observed": []} + # deepcopy semantics: resetting twice must not alias the same list + controller.context["observed"].append("junk") + controller.reset() + assert controller.context == {"observed": []} + + def test_reset_clears_history_and_log(self): + def controller_function(**kwargs): + kwargs["controlled_objects"].set_control("deflection", 1.0) + return "entry" + + controller, surface = make_controller(controller_function=controller_function) + controller(time=1.0, state=STATE) + controller.reset() + + assert controller.control_history == {"canard": {"deflection": []}} + assert controller.log == [] + assert controller.triggered_times == [] + + def test_reset_restores_controlled_object_initial_state(self): + controller, surface = make_controller() + surface.set_control("deflection", 0.7) + controller.reset() + assert surface.control_state == surface.initial_control_state + assert surface.get_control("deflection") == 0.0 + + def test_controllable_surface_reset_restores_all_controls(self): + surface = make_surface(controls=("delta_pitch", "delta_yaw")) + surface.set_control("delta_pitch", 0.3) + surface.set_control("delta_yaw", -0.1) + surface._reset() + assert surface.control_state == {"delta_pitch": 0.0, "delta_yaw": 0.0} + + +class TestBindControlledObjects: + def test_rebinding_switches_tracked_object(self): + controller, _ = make_controller() + replacement = make_surface("replacement") + controller.bind_controlled_objects(replacement) + + controller(time=1.0, state=STATE) + assert list(controller.control_history) == ["replacement"] + assert controller.controlled_objects is replacement + + def test_rebinding_restores_friendly_name_bindings(self): + received = {} + + def controller_function(**kwargs): + received.update(kwargs) + + controller = Controller( + controller_function, controlled_objects=[], sampling_rate=10 + ) + surface = make_surface() + controller.bind_controlled_objects(surface, "air_brakes") + controller(time=1.0, state=STATE) + assert received["air_brakes"] is surface + + +class TestSerialization: + def test_to_dict_from_dict_round_trip(self): + def controller_function(**kwargs): + return kwargs["time"] + + controller, _ = make_controller( + controller_function=controller_function, + name="Roundtrip", + context={"gain": 2.0}, + controlled_objects_name="canard_surface", + ) + data = controller.to_dict() + surface = make_surface() + rebuilt = Controller.from_dict(data, controlled_objects=surface) + + assert rebuilt.name == "Roundtrip" + assert rebuilt.sampling_rate == 10 + assert rebuilt.context == {"gain": 2.0} + assert rebuilt.controlled_objects is surface + rebuilt(time=3.0, state=STATE) + assert rebuilt.log == [3.0] + + def test_from_dict_without_objects_keeps_pending_name(self): + controller, _ = make_controller(controlled_objects_name="canard_surface") + rebuilt = Controller.from_dict(controller.to_dict()) + + assert rebuilt.controlled_objects == [] + assert rebuilt.controlled_objects_name == "canard_surface" + + surface = make_surface() + rebuilt.bind_controlled_objects(surface, rebuilt.controlled_objects_name) + assert list(rebuilt.control_history) == ["canard_surface"] diff --git a/tests/unit/control/test_scheduled_controller.py b/tests/unit/control/test_scheduled_controller.py new file mode 100644 index 000000000..64069e030 --- /dev/null +++ b/tests/unit/control/test_scheduled_controller.py @@ -0,0 +1,158 @@ +"""Unit tests for ScheduledController: schedule normalization, open-loop +replay with clamping, from_controller fidelity, and the serialization +fallback when a controller function cannot be restored.""" + +import numpy as np +import pytest + +from rocketpy import ( + AirBrakes, + ControllableGenericSurface, + Controller, + Function, + ScheduledController, +) + +STATE = [0.0] * 13 + + +def make_surface(name="canard"): + return ControllableGenericSurface( + reference_area=0.01, reference_length=0.1, coefficients={}, name=name + ) + + +class TestScheduleNormalization: + def test_flat_schedule_maps_to_single_object(self): + surface = make_surface() + controller = ScheduledController( + {"deflection": [(0, 0.0), (10, 1.0)]}, surface, sampling_rate=10 + ) + assert list(controller.schedule) == ["canard"] + assert isinstance(controller.schedule["canard"]["deflection"], Function) + + def test_flat_schedule_with_multiple_objects_rejected(self): + surfaces = [make_surface("a"), make_surface("b")] + with pytest.raises(ValueError, match="exactly one"): + ScheduledController({"deflection": [(0, 0.0)]}, surfaces, sampling_rate=10) + + def test_nested_schedule_and_source_forms(self): + surfaces = [make_surface("a"), make_surface("b")] + controller = ScheduledController( + { + "a": {"deflection": Function(lambda t: 0.5)}, + "b": {"deflection": 0.3}, + }, + surfaces, + sampling_rate=10, + ) + controller(time=1.0, state=STATE) + assert surfaces[0].get_control("deflection") == 0.5 + assert surfaces[1].get_control("deflection") == 0.3 + + def test_non_dict_schedule_rejected(self): + with pytest.raises(TypeError, match="schedule must be a dict"): + ScheduledController([(0, 0.0)], make_surface(), sampling_rate=10) + + +class TestReplay: + def test_interpolated_replay(self): + surface = make_surface() + controller = ScheduledController( + {"deflection": [(0, 0.0), (10, 1.0)]}, surface, sampling_rate=10 + ) + controller(time=2.5, state=STATE) + assert surface.get_control("deflection") == pytest.approx(0.25) + # constant extrapolation past the schedule + controller(time=20.0, state=STATE) + assert surface.get_control("deflection") == pytest.approx(1.0) + + def test_replay_applies_clamping(self): + air_brakes = AirBrakes( + drag_coefficient_curve="data/rockets/calisto/air_brakes_cd.csv", + reference_area=0.01, + clamp=True, + ) + controller = ScheduledController( + {"deployment_level": [(0, 0.0), (1, 5.0)]}, air_brakes, sampling_rate=10 + ) + controller(time=1.0, state=STATE) + assert air_brakes.deployment_level == 1 # clamped from 5.0 + + def test_from_controller_replays_recorded_history(self): + surface = make_surface() + + def law(**kwargs): + kwargs["controlled_objects"].set_control("deflection", 0.1 * kwargs["time"]) + + source = Controller(law, surface, sampling_rate=10) + for time in (1.0, 2.0, 3.0): + source(time=time, state=STATE) + + replay = ScheduledController.from_controller(source) + assert replay.sampling_rate == 10 + assert replay.name == "Controller (replay)" + for time in (1.0, 1.5, 3.0): + replay(time=time, state=STATE) + assert surface.get_control("deflection") == pytest.approx(0.1 * time) + + def test_from_controller_without_history_rejected(self): + source = Controller(lambda **kwargs: None, make_surface(), sampling_rate=10) + with pytest.raises(ValueError, match="no recorded control history"): + ScheduledController.from_controller(source) + + +class TestSerializationFallback: + def build_recorded_controller(self): + surface = make_surface() + + def law(**kwargs): + kwargs["controlled_objects"].set_control("deflection", 0.1 * kwargs["time"]) + + controller = Controller(law, surface, sampling_rate=10) + for time in (1.0, 2.0, 3.0): + controller(time=time, state=STATE) + return controller + + def test_undecodable_function_with_history_falls_back_to_replay(self): + data = self.build_recorded_controller().to_dict(include_outputs=True) + data["controller_function"] = "" + + with pytest.warns(UserWarning, match="ScheduledController"): + fallback = Controller.from_dict(data) + assert isinstance(fallback, ScheduledController) + + surface = make_surface() + fallback.bind_controlled_objects(surface, fallback.controlled_objects_name) + fallback(time=2.5, state=STATE) + assert surface.get_control("deflection") == pytest.approx(0.25) + + def test_undecodable_function_without_history_raises(self): + data = self.build_recorded_controller().to_dict(include_outputs=False) + data["controller_function"] = "" + with pytest.raises(ValueError, match="Could not restore"): + Controller.from_dict(data) + + def test_history_restored_from_dict(self): + data = self.build_recorded_controller().to_dict(include_outputs=True) + rebuilt = Controller.from_dict(data) + schedule = rebuilt.recorded_schedule["canard"]["deflection"] + assert schedule(2.0) == pytest.approx(0.2) + assert rebuilt._controlled_objects_ref == ["canard"] + + def test_scheduled_controller_round_trip(self): + surface = make_surface() + controller = ScheduledController( + {"deflection": np.array([[0, 0.0], [10, 1.0]])}, + surface, + sampling_rate=10, + name="Replay", + ) + data = controller.to_dict() + assert data["controller_function"] is None + + rebuilt = ScheduledController.from_dict(data) + fresh = make_surface() + rebuilt.bind_controlled_objects(fresh) + rebuilt(time=5.0, state=STATE) + assert fresh.get_control("deflection") == pytest.approx(0.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_air_brakes.py b/tests/unit/rocket/aero_surface/test_air_brakes.py new file mode 100644 index 000000000..e01df852a --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_air_brakes.py @@ -0,0 +1,122 @@ +"""Unit tests for the AirBrakes surface: drag coefficient rules, clamping, +reset protocol, and serialization schema.""" + +import numpy as np +import pytest + +from rocketpy import AirBrakes + +DRAG_CURVE = "data/rockets/calisto/air_brakes_cd.csv" +REFERENCE_AREA = 0.01 + + +def make_air_brakes(**kwargs): + defaults = { + "drag_coefficient_curve": DRAG_CURVE, + "reference_area": REFERENCE_AREA, + } + defaults.update(kwargs) + return AirBrakes(**defaults) + + +class TestDragCoefficient: + def test_drag_coefficient_is_two_dimensional(self): + air_brakes = make_air_brakes() + assert air_brakes.drag_coefficient.__dom_dim__ == 2 + + @pytest.mark.parametrize("override", [False, True]) + def test_zero_deployment_gives_zero_cd(self, override): + air_brakes = make_air_brakes(override_rocket_drag=override) + air_brakes.deployment_level = 0 + cd = air_brakes.cD.get_value_opt( + *air_brakes._coefficient_arguments(0, 0, 0.5, 1e6, 0, 0, 0) + ) + assert cd == 0.0 + + def test_deployed_cd_matches_curve(self): + air_brakes = make_air_brakes() + air_brakes.deployment_level = 0.5 + for mach in (0.2, 0.5, 0.8): + cd = air_brakes.cD.get_value_opt( + *air_brakes._coefficient_arguments(0, 0, mach, 1e6, 0, 0, 0) + ) + assert cd == pytest.approx( + air_brakes.drag_coefficient.get_value_opt(0.5, mach) + ) + + def test_cd_independent_of_incidence_and_rates(self): + air_brakes = make_air_brakes() + air_brakes.deployment_level = 0.7 + reference = air_brakes.cD.get_value_opt( + *air_brakes._coefficient_arguments(0, 0, 0.5, 1e6, 0, 0, 0) + ) + perturbed = air_brakes.cD.get_value_opt( + *air_brakes._coefficient_arguments(0.1, -0.05, 0.5, 5e5, 0.01, 0.02, 0.03) + ) + assert perturbed == pytest.approx(reference) + + +class TestClamping: + def test_clamp_on_bounds_deployment_level(self): + air_brakes = make_air_brakes(clamp=True) + air_brakes.deployment_level = 1.5 + assert air_brakes.deployment_level == 1 + air_brakes.deployment_level = -1 + assert air_brakes.deployment_level == 0 + air_brakes.deployment_level = 0.5 + assert air_brakes.deployment_level == 0.5 + + def test_clamp_off_warns_and_keeps_value(self): + air_brakes = make_air_brakes(clamp=False) + with pytest.warns(UserWarning, match="Extrapolation"): + air_brakes.deployment_level = 1.5 + assert air_brakes.deployment_level == 1.5 + with pytest.warns(UserWarning, match="Extrapolation"): + air_brakes.deployment_level = -1 + assert air_brakes.deployment_level == -1 + + +class TestResetProtocol: + def test_reset_restores_initial_deployment_level(self): + air_brakes = make_air_brakes(deployment_level=0.3) + assert air_brakes.initial_control_state == {"deployment_level": 0.3} + air_brakes.deployment_level = 0.9 + air_brakes._reset() + assert air_brakes.deployment_level == 0.3 + + def test_reset_reapplies_clamped_initial_value(self): + air_brakes = make_air_brakes(deployment_level=1.5, clamp=True) + assert air_brakes.deployment_level == 1 + air_brakes.deployment_level = 0.2 + air_brakes._reset() + assert air_brakes.deployment_level == 1 + + def test_control_state_backs_deployment_level(self): + air_brakes = make_air_brakes() + air_brakes.set_control("deployment_level", 0.4) + assert air_brakes.deployment_level == 0.4 + assert air_brakes.get_control("deployment_level") == 0.4 + assert air_brakes.control_variables == ["deployment_level"] + + +class TestSerialization: + def test_to_dict_from_dict_round_trip(self): + air_brakes = make_air_brakes( + clamp=False, + override_rocket_drag=True, + deployment_level=0.2, + name="Brakes", + ) + data = air_brakes.to_dict() + rebuilt = AirBrakes.from_dict(data) + + assert rebuilt.name == "Brakes" + assert rebuilt.clamp is False + assert rebuilt.override_rocket_drag is True + assert rebuilt.reference_area == REFERENCE_AREA + assert rebuilt.initial_deployment_level == 0.2 + assert rebuilt.deployment_level == 0.2 + for mach in np.linspace(0.1, 0.9, 5): + assert rebuilt.drag_coefficient.get_value_opt(0.5, mach) == pytest.approx( + air_brakes.drag_coefficient.get_value_opt(0.5, mach) + ) 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..b2bd32f71 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py @@ -0,0 +1,161 @@ +"""Regression tests for the GenericSurface-rooted aerodynamic hierarchy. + +After the refactor, every aerodynamic surface (Barrowman or generic) is +described by the generic coefficient model and exposes the diagnostic accessors +``lift_coefficient_derivative`` and ``center_of_pressure_z`` used by the rocket's +center-of-pressure / stability-margin computation. These tests pin the +properties that the refactor is meant to guarantee. +""" + +import warnings + +import numpy as np +import pytest + +from rocketpy import LinearGenericSurface, NoseCone, Tail, TrapezoidalFins + + +def test_barrowman_derived_cp_matches_geometric_cp(): + """The derived ``center_of_pressure_z`` must reproduce the geometric cp of + each Barrowman surface (the moment is carried by ``cm`` but the diagnostic + must recover the original location).""" + nose = NoseCone( + length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 + ) + tail = Tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, rocket_radius=0.0635 + ) + fins = TrapezoidalFins( + n=4, span=0.100, root_chord=0.120, tip_chord=0.040, rocket_radius=0.0635 + ) + + for surface in (nose, tail, fins): + for mach in (0.0, 0.5, 0.9): + assert ( + pytest.approx( + surface.center_of_pressure_z.get_value_opt(mach), rel=1e-6, abs=1e-9 + ) + == surface.cpz + ) + # The normal-force slope diagnostic must equal the Barrowman clalpha. + assert pytest.approx( + nose.lift_coefficient_derivative.get_value_opt(0.0) + ) == nose.clalpha.get_value_opt(0.0) + + +def test_generic_surface_contributes_to_static_margin(calisto_motorless): + """A generic surface must now contribute to the rocket center of pressure + (previously generic surfaces were skipped, breaking stability margin).""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + + cp_without_generic = rocket.aerodynamic_center.get_value_opt(0.2) + + # A lifting generic surface placed aft should move the cp aft (more stable). + generic = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cL_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, + }, + name="generic_fins", + ) + rocket.add_surfaces(generic, positions=-1.0) + + cp_with_generic = rocket.aerodynamic_center.get_value_opt(0.2) + assert cp_with_generic != pytest.approx(cp_without_generic) + assert np.isfinite(cp_with_generic) + + +def test_zero_lift_surface_does_not_break_cp(calisto_motorless): + """A surface with no normal-force slope must drop out of the lift-weighted + cp average without producing NaNs.""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + cp_reference = rocket.aerodynamic_center.get_value_opt(0.2) + + drag_only = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={"cD_0": lambda a, b, m, re, p, q, r: 0.5}, + name="drag_only", + ) + rocket.add_surfaces(drag_only, positions=-1.0) + + cp_after = rocket.aerodynamic_center.get_value_opt(0.2) + assert np.isfinite(cp_after) + assert cp_after == pytest.approx(cp_reference) + + +def test_axisymmetric_rocket_pitch_equals_yaw_margin(calisto_motorless): + """An axisymmetric rocket must have identical pitch and yaw margins and + must not raise the asymmetry warning.""" + rocket = calisto_motorless + with warnings.catch_warnings(): + warnings.simplefilter("error") # asymmetry warning would fail the test + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, span=0.100, root_chord=0.120, tip_chord=0.040, position=-1.04 + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194 + ) + + for mach in (0.0, 0.5, 0.9): + assert rocket.aerodynamic_center.get_value_opt(mach) == pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(mach), abs=1e-9 + ) + assert rocket.static_margin.get_value_opt(0) == pytest.approx( + rocket.static_margin_yaw.get_value_opt(0), abs=1e-9 + ) + + +def test_non_axisymmetric_rocket_splits_margins_and_warns(calisto_motorless): + """A non-axisymmetric generic surface must yield distinct pitch/yaw margins + and raise a warning that the scalar margin describes the pitch plane only. + + The advisory is emitted lazily -- on the first evaluation of the aerodynamic + center, not eagerly at add time -- so adding the surface itself is silent.""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + asymmetric = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cL_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, + "cQ_beta": lambda a, b, m, re, p, q, r: -2.0, + "cn_beta": lambda a, b, m, re, p, q, r: 2.0, + }, + name="asym", + ) + + rocket.add_surfaces(asymmetric, positions=-1.0) + + # Warning fires once, on the first aerodynamic-center evaluation. + with pytest.warns(UserWarning, match="not\\s+axisymmetric"): + ac_pitch = rocket.aerodynamic_center.get_value_opt(0.2) + + assert ac_pitch != pytest.approx(rocket.aerodynamic_center_yaw.get_value_opt(0.2)) + assert rocket.static_margin.get_value_opt(0) != pytest.approx( + rocket.static_margin_yaw.get_value_opt(0) + ) + + +def test_barrowman_surface_uses_generic_compute_path(): + """Barrowman surfaces must route through the shared generic + ``compute_forces_and_moments`` (no bespoke override) and apply their force + at the origin (moment carried by the coefficients).""" + from rocketpy.rocket.aero_surface.generic_surface import GenericSurface + + nose = NoseCone( + length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 + ) + assert isinstance(nose, GenericSurface) + # Force is applied at the origin; the cp offset lives in cm/cn. + assert tuple(nose.force_application_point) == (0, 0, 0) + assert ( + nose.compute_forces_and_moments.__func__ + is GenericSurface.compute_forces_and_moments + ) diff --git a/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py new file mode 100644 index 000000000..837803648 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py @@ -0,0 +1,101 @@ +"""Unit tests for ControllableGenericSurface and the controllable-surface +controller linkage.""" + +import pytest + +from rocketpy import ControllableGenericSurface, Function, GenericSurface +from rocketpy.mathutils.vector_matrix import Vector + +DENSITY = Function(lambda z: 1.16) +VISCOSITY = Function(lambda z: 1.8e-5) + + +def _moment_at_deflection(surface, deflection, comp="pitch"): + surface.set_control("deflection", deflection) + r1, r2, r3, m1, m2, m3 = surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + ) + return {"pitch": m1, "yaw": m2, "roll": m3}[comp] + + +def test_control_variable_extends_independent_vars(): + surface = ControllableGenericSurface( + reference_area=1, reference_length=0.2, coefficients={} + ) + assert surface.independent_vars[:7] == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + assert surface.independent_vars[7:] == ["deflection"] + assert surface.control_state == {"deflection": 0.0} + + +def test_deflection_produces_proportional_control_moment(): + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cm": lambda a, b, m, re, p, q, r, deflection: 0.5 * deflection}, + ) + m0 = _moment_at_deflection(surface, 0.0) + m1 = _moment_at_deflection(surface, 0.1) + m2 = _moment_at_deflection(surface, 0.2) + assert m0 == pytest.approx(0.0) + assert m2 == pytest.approx(2 * m1) + assert m1 != pytest.approx(0.0) + + +def test_multiple_named_controls(): + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cn": lambda a, b, m, re, p, q, r, dp, dy: 0.3 * dy}, + controls=("delta_pitch", "delta_yaw"), + ) + assert surface.independent_vars[7:] == ["delta_pitch", "delta_yaw"] + surface.set_control("delta_yaw", 0.5) + yaw = surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + )[4] + assert yaw != pytest.approx(0.0) + + +def test_set_control_unknown_name_raises(): + surface = ControllableGenericSurface( + reference_area=1, reference_length=0.2, coefficients={} + ) + with pytest.raises(KeyError): + surface.set_control("not_a_control", 0.1) + + +def test_plain_generic_surface_default_independent_vars_unchanged(): + surface = GenericSurface(reference_area=1, reference_length=0.2, coefficients={}) + assert surface.independent_vars == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] diff --git a/tests/unit/rocket/aero_surface/test_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_generic_surfaces.py index c16a1b592..43543a50e 100644 --- a/tests/unit/rocket/aero_surface/test_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_generic_surfaces.py @@ -89,12 +89,10 @@ def test_csv_independent_variables_accept_any_order(tmp_path): coefficients={"cL": str(filename)}, ) - closure = generic_surface.cL.source.__closure__ - csv_function = next( - cell.cell_contents - for cell in closure - if isinstance(cell.cell_contents, Function) - ) + # The coefficient is stored at minimal dimension over its CSV columns, in + # header order; AeroCoefficient maps the full argument tuple onto them. + assert generic_surface.cL.depends_on == ("mach", "alpha") + csv_function = generic_surface.cL.function assert generic_surface.cL(1, 0, 2, 0, 0, 0, 0) == pytest.approx(12) assert csv_function.get_interpolation_method() == "regular_grid" @@ -117,3 +115,32 @@ def test_compute_forces_and_moments(): z=0, ) assert forces_and_moments == (0, 0, 0, 0, 0, 0) + + +def test_angular_rates_are_non_dimensionalized(): + """Coefficients receive the conventional reduced rate q* = q L_ref / (2 V), + not the raw body rate in rad/s.""" + ref_area, ref_length = 2.0, 0.5 + # Roll-moment coefficient that simply returns the roll rate it is given, so + # the resulting roll moment exposes which rate value reached the coefficient. + gs = GenericSurface(ref_area, ref_length, {"cl": lambda roll_rate: roll_rate}) + + rho, speed, raw_roll = 1.2, 10.0, 4.0 + *_, roll_moment = gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # along centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, raw_roll), # raw body roll rate p, rad/s + density=Function(1.0), + dynamic_viscosity=Function(1.0), + z=0, + ) + + reduced_roll = raw_roll * ref_length / (2 * speed) + dyn_pressure_area_length = 0.5 * rho * speed**2 * ref_area * ref_length + # The coefficient saw the reduced rate, ... + assert roll_moment == pytest.approx(dyn_pressure_area_length * reduced_roll) + # ... not the raw rad/s rate. + assert roll_moment != pytest.approx(dyn_pressure_area_length * raw_roll) diff --git a/tests/unit/rocket/aero_surface/test_individual_fins.py b/tests/unit/rocket/aero_surface/test_individual_fins.py index d232e0772..6db540a8a 100644 --- a/tests/unit/rocket/aero_surface/test_individual_fins.py +++ b/tests/unit/rocket/aero_surface/test_individual_fins.py @@ -7,7 +7,9 @@ from rocketpy import ( EllipticalFin, + EllipticalFins, FreeFormFin, + FreeFormFins, Rocket, TrapezoidalFin, TrapezoidalFins, @@ -375,16 +377,124 @@ def test_calisto_finset_vs_four_individual_fins_close(): mach_grid = np.linspace(0, 2, 21) # Act - cp_finset = finset_rocket.cp_position(mach_grid) - cp_individual = individual_fins_rocket.cp_position(mach_grid) + cp_finset = finset_rocket.aerodynamic_center(mach_grid) + cp_individual = individual_fins_rocket.aerodynamic_center(mach_grid) clalpha_finset = finset_rocket.total_lift_coeff_der(mach_grid) clalpha_individual = individual_fins_rocket.total_lift_coeff_der(mach_grid) - lift_correction = TrapezoidalFins.fin_num_correction(4) / 4 - clalpha_individual_corrected = np.array(clalpha_individual) * lift_correction - # Assert + # Assert. Each individual fin projects its lift slope onto the pitch plane by + # sin(phi)**2, so an evenly spaced set of 4 sums to fin_num_correction(4) = 2 + # in the plane -- matching the fin set directly, with no extra correction. np.testing.assert_allclose(cp_individual, cp_finset, rtol=1e-6, atol=1e-6) - np.testing.assert_allclose(clalpha_individual_corrected, clalpha_finset) + np.testing.assert_allclose(clalpha_individual, clalpha_finset) + + +@pytest.mark.parametrize( + "fin_cls, geometry", + [ + ( + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + (EllipticalFin, dict(root_chord=0.120, span=0.100, rocket_radius=0.0635)), + ( + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_canted_individual_fin_builds_and_places(fin_cls, geometry): + """A canted individual fin of any shape must build its body<->fin rotation + matrices at construction, so it can be placed on a rocket. Regression test + for a crash where non-trapezoidal individual fins lacked + ``_rotation_fin_to_body_uncanted`` and failed in + ``_compute_leading_edge_position``.""" + fin = fin_cls(angular_position=30, cant_angle=2.0, **geometry) + assert hasattr(fin, "_rotation_fin_to_body_uncanted") + position = fin._compute_leading_edge_position(-1.168, 1) + assert position is not None + + +@pytest.mark.parametrize( + "fin_cls, geometry", + [ + ( + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + (EllipticalFin, dict(root_chord=0.120, span=0.100, rocket_radius=0.0635)), + ( + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_individual_fin_roll_moment_independent_of_angular_position(fin_cls, geometry): + """A canted individual fin's roll moment must be the same at any angular + position (rotational symmetry about the roll axis). Regression test for a bug + where the fin's center of pressure was not rotated to its azimuth (the + rotation matrix was left as the identity), making the roll moment vary with + angular position.""" + stream_velocity = Vector([0, 0, -1.0]) + omega = Vector([0, 0, 0]) + + roll_moments = [] + for angle in (0, 90, 180, 270): + rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + fin = fin_cls(angular_position=angle, cant_angle=2.0, **geometry) + rocket.add_surfaces(fin, -1.168) + cp = rocket.surfaces_cp_to_cdm[fin] + roll = fin.compute_forces_and_moments( + stream_velocity, 1.0, 0.3, 1.0, cp, omega + )[5] + roll_moments.append(roll) + + np.testing.assert_allclose(roll_moments, roll_moments[0], rtol=1e-9) + assert abs(roll_moments[0]) > 0 + + +@pytest.mark.parametrize( + "set_cls, fin_cls, geometry", + [ + ( + TrapezoidalFins, + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + ( + EllipticalFins, + EllipticalFin, + dict(root_chord=0.120, span=0.100, rocket_radius=0.0635), + ), + ( + FreeFormFins, + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_finset_roll_forcing_equals_n_single_fins(set_cls, fin_cls, geometry): + """A fin set's roll forcing coefficient must scale with the full fin count + ``n`` (every identically-canted fin adds the same roll moment), so it equals + ``n`` times a single fin's roll forcing -- for every fin shape. Regression + test for a bug where the set used the normal-force ``fin_num_correction(n)`` + (~n/2), halving the roll forcing (and roll rate) of a fin set.""" + n = 4 + finset = set_cls(n=n, cant_angle=2.0, **geometry) + single = fin_cls(angular_position=0, cant_angle=2.0, **geometry) + + mach_grid = np.linspace(0, 2, 11) + clf_finset = finset.roll_parameters[0](mach_grid) + clf_single = single.roll_parameters[0](mach_grid) + np.testing.assert_allclose(clf_finset, n * np.array(clf_single), rtol=1e-6) @pytest.mark.parametrize( diff --git a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py index 4f0695143..88d7973cb 100644 --- a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py @@ -91,3 +91,33 @@ def test_compute_forces_and_moments(): z=0, ) assert forces_and_moments == (0, 0, 0, 0, 0, 0) + + +def test_roll_damping_uses_reduced_rate(): + """The roll-damping derivative cl_p is applied to the reduced roll rate + p* = p L_ref / (2 V). This must equal the previous raw-rate scaling + (0.5 rho V A L^2 / 2) * cl_p * p, confirming the change is result-identical + for the linear model.""" + ref_area, ref_length, cl_p = 2.0, 0.5, 3.0 + lgs = LinearGenericSurface(ref_area, ref_length, {"cl_p": cl_p}) + + rho, speed, raw_roll = 1.2, 10.0, 4.0 + *_, roll_moment = lgs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # along centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, raw_roll), # raw body roll rate p, rad/s + density=Function(1.0), + dynamic_viscosity=Function(1.0), + z=0, + ) + + reduced_roll = raw_roll * ref_length / (2 * speed) + dyn_pressure_area_length = 0.5 * rho * speed**2 * ref_area * ref_length + # New (reduced-rate) formulation: + assert roll_moment == pytest.approx(dyn_pressure_area_length * cl_p * reduced_roll) + # Old (raw-rate) formulation -- identical value: + old_damping_scaling = 0.5 * rho * speed * ref_area * ref_length**2 / 2 + assert roll_moment == pytest.approx(old_damping_scaling * cl_p * raw_roll) diff --git a/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py b/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py new file mode 100644 index 000000000..893f90faf --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_unsteady_generic_surface.py @@ -0,0 +1,71 @@ +"""Unit tests for the optional alpha_dot/beta_dot unsteady coefficient axes of +GenericSurface.""" + +import pytest + +from rocketpy import Function, GenericSurface +from rocketpy.mathutils.vector_matrix import Vector + +DENSITY = Function(lambda z: 1.16) +VISCOSITY = Function(lambda z: 1.8e-5) + + +def _pitch_moment(surface, alpha_dot): + return surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + alpha_dot=alpha_dot, + beta_dot=0.0, + )[3] + + +def test_unsteady_axes_extend_independent_vars(): + surface = GenericSurface( + reference_area=1, reference_length=0.2, coefficients={}, unsteady_aero=True + ) + assert surface.independent_vars[7:] == ["alpha_dot", "beta_dot"] + + +def test_prescribed_alpha_dot_produces_unsteady_pitch_moment(): + surface = GenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={ + "cm": lambda a, b, m, re, p, q, r, alpha_dot, beta_dot: 0.7 * alpha_dot + }, + unsteady_aero=True, + ) + m0 = _pitch_moment(surface, 0.0) + m1 = _pitch_moment(surface, 0.5) + m2 = _pitch_moment(surface, 1.0) + assert m0 == pytest.approx(0.0) + assert m1 != pytest.approx(0.0) + assert m2 == pytest.approx(2 * m1) + + +def test_default_surface_ignores_alpha_dot_and_stays_seven_var(): + """Existing 7-variable surfaces must be unaffected: independent vars + unchanged and alpha_dot/beta_dot ignored at evaluation.""" + surface = GenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cm": lambda a, b, m, re, p, q, r: 0.1}, + ) + assert surface.independent_vars == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + # passing nonzero alpha_dot must not change the result + assert _pitch_moment(surface, 0.0) == pytest.approx(_pitch_moment(surface, 99.0)) diff --git a/tests/unit/rocket/test_effector.py b/tests/unit/rocket/test_effector.py new file mode 100644 index 000000000..378d3df18 --- /dev/null +++ b/tests/unit/rocket/test_effector.py @@ -0,0 +1,181 @@ +"""Tests for control effectors: the Effector / GenericEffector force-moment model, +its control state, serialization, and Rocket.add_effector wiring.""" + +import numpy as np +import pytest + +from rocketpy import GenericEffector +from rocketpy.mathutils.vector_matrix import Vector + + +def test_generic_effector_force_and_moment_mapping(): + """The effector returns its body-frame force and the moment about the CDM + (its own moment plus ``force_arm x force``).""" + effector = GenericEffector( + force=lambda **k: (0.0, k["side"], 0.0), + moment=lambda **k: (0.0, 0.0, k["roll"]), + controls=("side", "roll"), + ) + effector.set_control("side", 10.0) + effector.set_control("roll", 4.0) + + arm = Vector([0.0, 0.0, -2.0]) # application point 2 m below the CDM + force, moment = effector.evaluate( + arm, 0.0, Vector([0, 0, 0]), Vector([0, 0, 0]), 0.3, None + ) + assert tuple(force) == (0.0, 10.0, 0.0) + # own moment (0, 0, 4) + arm x force = (0,0,-2) x (0,10,0) = (20, 0, 0) + assert tuple(moment) == pytest.approx((20.0, 0.0, 4.0)) + + +def test_generic_effector_defaults_to_zero(): + """A force/moment left as None contributes nothing.""" + effector = GenericEffector(controls=("x",)) + force, moment = effector.evaluate( + Vector([0, 0, 1]), 0.0, Vector([0, 0, 0]), Vector([0, 0, 0]), 0.0, None + ) + assert tuple(force) == (0.0, 0.0, 0.0) + assert tuple(moment) == (0.0, 0.0, 0.0) + + +def test_effector_control_state_set_get_reset(): + """Control state round-trips through set/get and resets to its initial value.""" + effector = GenericEffector(controls=("torque",)) + assert effector.get_control("torque") == 0.0 + effector.set_control("torque", 3.0) + assert effector.get_control("torque") == 3.0 + effector._reset() + assert effector.get_control("torque") == 0.0 + with pytest.raises(KeyError): + effector.set_control("unknown", 1.0) + + +def test_generic_effector_serialization_roundtrip(): + """to_dict/from_dict preserves the force/moment callables, position, controls + and name, and evaluates identically.""" + effector = GenericEffector( + force=lambda **k: (0.0, k["cmd"], 0.0), + position=-1.2, + controls=("cmd",), + name="Side RCS", + ) + restored = GenericEffector.from_dict(effector.to_dict()) + assert restored.name == "Side RCS" + assert restored.position == -1.2 + assert restored.control_variables == ["cmd"] + + for eff in (effector, restored): + eff.set_control("cmd", 5.0) + arm = Vector([0, 0, -1.0]) + f0, m0 = effector.evaluate(arm, 0, Vector([0, 0, 0]), Vector([0, 0, 0]), 0.2, None) + f1, m1 = restored.evaluate(arm, 0, Vector([0, 0, 0]), Vector([0, 0, 0]), 0.2, None) + assert tuple(f1) == pytest.approx(tuple(f0)) + assert tuple(m1) == pytest.approx(tuple(m0)) + + +def test_add_effector_registers_and_wires_controller(calisto_motorless): + """add_effector stores the effector, computes its moment arm, keeps it out of + the aerodynamic surfaces, and (with a controller_function) registers a + Controller driving it.""" + rocket = calisto_motorless + + def law(**kwargs): + kwargs["rcs"].set_control("torque", 1.0) + + effector = GenericEffector( + moment=lambda **k: (0.0, 0.0, k["torque"]), controls=("torque",), name="RCS" + ) + controller = rocket.add_effector( + effector, + position=0.3, + controller_function=law, + sampling_rate=50, + controlled_objects_name="rcs", + ) + + assert effector in rocket.effectors + assert effector in rocket.effectors_cp_to_cdm + # arm z = (position - cdm) * csys + expected_z = (0.3 - rocket.center_of_dry_mass_position) * rocket._csys + assert rocket.effectors_cp_to_cdm[effector].z == pytest.approx(expected_z) + # An effector is not an aerodynamic surface (no CoP / stability impact). + assert effector not in [s for s, _ in rocket.aerodynamic_surfaces] + # A controller was created and drives the effector. + assert controller in rocket._controllers + assert controller.controlled_objects is effector + + +def test_add_effector_open_loop_returns_effector(calisto_motorless): + """Without a controller_function, add_effector registers a fixed effector and + creates no controller.""" + rocket = calisto_motorless + effector = GenericEffector(controls=("cmd",), name="Fixed") + returned = rocket.add_effector(effector, position=0.0) + assert returned is effector + assert rocket._controllers == [] + + +@pytest.mark.slow +def test_roll_damper_effector_reduces_roll(cesaroni_m1670, example_plain_env): + """End-to-end: a roll-rate-damper effector measurably reduces the peak roll + rate versus the same (canted-fin) rocket without it.""" + from rocketpy import Flight, Rocket + + env = example_plain_env + env.set_atmospheric_model(type="standard_atmosphere") + + def build(with_damper): + rocket = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=0.5, + power_on_drag=0.5, + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(cesaroni_m1670, position=-1.373) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, root_chord=0.12, tip_chord=0.06, span=0.11, position=-1.04956 + ) + # Canted tabs spin the rocket up (the disturbance to be damped). + rocket.add_trapezoidal_fins( + n=3, + root_chord=0.05, + tip_chord=0.03, + span=0.04, + position=-0.95, + cant_angle=2.0, + name="Roll tabs", + ) + if with_damper: + + def damper(**kwargs): + kwargs["rcs"].set_control("torque", -0.04 * kwargs["state"][12]) + + rocket.add_effector( + GenericEffector( + moment=lambda **k: (0.0, 0.0, k["torque"]), + controls=("torque",), + name="Roll RCS", + ), + controller_function=damper, + sampling_rate=50, + controlled_objects_name="rcs", + ) + return rocket + + def peak_roll(with_damper): + flight = Flight( + rocket=build(with_damper), + environment=env, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=True, + ) + times = np.linspace(0, flight.apogee_time, 300) + return max(abs(flight.w3.get_value_opt(t)) for t in times) + + assert peak_roll(True) < peak_roll(False) diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 3c7725fa5..623eebf1a 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -34,7 +34,7 @@ def test_evaluate_static_margin_assert_cp_equals_cm(dimensionless_calisto): rocket.center_of_mass(burn_time[1]) / (2 * rocket.radius), 1e-8 ) == pytest.approx(rocket.static_margin(burn_time[1]), 1e-8) assert pytest.approx(rocket.total_lift_coeff_der(0), 1e-8) == pytest.approx(0, 1e-8) - assert pytest.approx(rocket.cp_position(0), 1e-8) == pytest.approx(0, 1e-8) + assert pytest.approx(rocket.aerodynamic_center(0), 1e-8) == pytest.approx(0, 1e-8) @pytest.mark.parametrize( @@ -53,7 +53,7 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calist assert static_margin_final == pytest.approx(calisto.static_margin(np.inf), 1e-8) assert clalpha == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) - assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) + assert calisto.aerodynamic_center(0) == pytest.approx(cpz, 1e-8) dimensionless_calisto.add_nose(length=0.55829 * m, kind=type_, position=(1.160) * m) assert pytest.approx(dimensionless_calisto.static_margin(0), 1e-8) == pytest.approx( @@ -66,8 +66,8 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calist dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): @@ -91,7 +91,7 @@ def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): assert np.abs(clalpha) == pytest.approx( np.abs(calisto.total_lift_coeff_der(0)), 1e-8 ) - assert calisto.cp_position(0) == cpz + assert calisto.aerodynamic_center(0) == cpz dimensionless_calisto.add_tail( top_radius=0.0635 * m, @@ -109,8 +109,8 @@ def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) @pytest.mark.parametrize( @@ -146,7 +146,9 @@ def test_add_trapezoidal_fins_sweep_angle( assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) - assert translate - calisto.cp_position(0) == pytest.approx(expected_cpz_cm, 0.01) + assert translate - calisto.aerodynamic_center(0) == pytest.approx( + expected_cpz_cm, 0.01 + ) @pytest.mark.parametrize( @@ -186,7 +188,9 @@ def test_add_trapezoidal_fins_sweep_length( assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) - assert translate - calisto.cp_position(0) == pytest.approx(expected_cpz_cm, 0.01) + assert translate - calisto.aerodynamic_center(0) == pytest.approx( + expected_cpz_cm, 0.01 + ) assert isinstance(calisto.aerodynamic_surfaces[0].component, NoseCone) @@ -223,7 +227,7 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): assert np.abs(clalpha) == pytest.approx( np.abs(calisto.total_lift_coeff_der(0)), 1e-8 ) - assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) + assert calisto.aerodynamic_center(0) == pytest.approx(cpz, 1e-8) dimensionless_calisto.add_trapezoidal_fins( 4, @@ -242,8 +246,8 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) @pytest.mark.parametrize( @@ -732,22 +736,16 @@ def test_drag_csv_header_order_independent_for_multivariable_input(tmp_path): drag_ordered = rocket_ordered.power_off_drag_7d(0, 0, 0.8, 0.15, 0, 0, 0) drag_swapped = rocket_swapped.power_off_drag_7d(0, 0, 0.8, 0.15, 0, 0, 0) - ordered_closure = rocket_ordered.power_off_drag_7d.source.__closure__ - swapped_closure = rocket_swapped.power_off_drag_7d.source.__closure__ - ordered_csv_function = next( - cell.cell_contents - for cell in ordered_closure - if isinstance(cell.cell_contents, Function) - ) - swapped_csv_function = next( - cell.cell_contents - for cell in swapped_closure - if isinstance(cell.cell_contents, Function) - ) + # The coefficient is stored at minimal dimension over the present columns, + # keyed by name, so column order in the header does not matter. + ordered_csv_function = rocket_ordered.power_off_drag_7d.function + swapped_csv_function = rocket_swapped.power_off_drag_7d.function assert drag_ordered == pytest.approx(0.95) assert drag_swapped == pytest.approx(0.95) assert drag_swapped == pytest.approx(drag_ordered) + assert set(rocket_ordered.power_off_drag_7d.depends_on) == {"mach", "reynolds"} + assert set(rocket_swapped.power_off_drag_7d.depends_on) == {"mach", "reynolds"} assert ordered_csv_function.get_interpolation_method() == "regular_grid" assert swapped_csv_function.get_interpolation_method() == "regular_grid" diff --git a/tests/unit/rocket/test_rocket_air_brakes.py b/tests/unit/rocket/test_rocket_air_brakes.py new file mode 100644 index 000000000..4df166dd1 --- /dev/null +++ b/tests/unit/rocket/test_rocket_air_brakes.py @@ -0,0 +1,168 @@ +"""Unit tests for wiring air brakes (and controllable surfaces) into the +Rocket: surface-loop membership, cp pinning, stability invariance, the +add_air_brakes compatibility surface, and save/load round trips.""" + +import json + +import pytest + +from rocketpy import ( + AirBrakesController, + ControllableGenericSurface, + SurfaceController, +) +from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder + +DRAG_CURVE = "data/rockets/calisto/air_brakes_cd.csv" + + +def kwargs_controller(**kwargs): + kwargs["air_brakes"].deployment_level = 0.5 + + +def add_air_brakes(rocket, **kwargs): + defaults = { + "drag_coefficient_curve": DRAG_CURVE, + "controller_function": kwargs_controller, + "sampling_rate": 10, + "return_controller": True, + } + defaults.update(kwargs) + return rocket.add_air_brakes(**defaults) + + +class TestAirBrakesInSurfaceLoop: + def test_air_brakes_join_aerodynamic_surfaces(self, calisto_motorless): + air_brakes, controller = add_air_brakes(calisto_motorless) + assert air_brakes in [s for s, _ in calisto_motorless.aerodynamic_surfaces] + assert air_brakes in calisto_motorless.air_brakes + assert controller in calisto_motorless._controllers + + def test_default_position_pins_cp_to_cdm(self, calisto_motorless): + air_brakes, _ = add_air_brakes(calisto_motorless) + assert air_brakes._pin_cp_to_cdm is True + assert tuple(calisto_motorless.surfaces_cp_to_cdm[air_brakes]) == (0, 0, 0) + + def test_pin_survives_add_motor(self, calisto_motorless, cesaroni_m1670): + air_brakes, _ = add_air_brakes(calisto_motorless) + calisto_motorless.add_motor(cesaroni_m1670, position=-1.373) + assert tuple(calisto_motorless.surfaces_cp_to_cdm[air_brakes]) == (0, 0, 0) + + def test_explicit_position_uses_standard_cp_path(self, calisto_motorless): + air_brakes, _ = add_air_brakes(calisto_motorless, position=-0.5) + assert air_brakes._pin_cp_to_cdm is False + cp_to_cdm = calisto_motorless.surfaces_cp_to_cdm[air_brakes] + assert tuple(cp_to_cdm) != (0, 0, 0) + + def test_static_margin_unchanged_by_air_brakes(self, calisto_robust): + margin_before = [calisto_robust.static_margin(t) for t in (0, 1, 3)] + add_air_brakes(calisto_robust) + margin_after = [calisto_robust.static_margin(t) for t in (0, 1, 3)] + assert margin_after == pytest.approx(margin_before) + + +class TestAddAirBrakesCompatibility: + def test_return_controller_type(self, calisto_motorless): + _, controller = add_air_brakes(calisto_motorless) + assert isinstance(controller, AirBrakesController) + + def test_kwargs_controller_function_not_wrapped(self, calisto_motorless): + _, controller = add_air_brakes(calisto_motorless) + assert controller.controller_function is kwargs_controller + + def test_legacy_positional_function_warns_and_works(self, calisto_motorless): + def legacy(time, sampling_rate, state, state_history, observed, air_brakes): + air_brakes.deployment_level = 0.25 + + with pytest.warns(DeprecationWarning, match="positional arguments"): + air_brakes, controller = add_air_brakes( + calisto_motorless, controller_function=legacy + ) + controller(time=1.0, state=[0.0] * 13) + assert air_brakes.deployment_level == 0.25 + assert controller.control_history["air_brakes"]["deployment_level"] == [ + (1.0, 0.25) + ] + + def test_initial_observed_variables_deprecated(self, calisto_motorless): + with pytest.warns(DeprecationWarning, match="initial_observed_variables"): + add_air_brakes(calisto_motorless, initial_observed_variables=[(0, 0, 0)]) + + +class TestAddControllableSurface: + def make_surface(self, name="Canards"): + return ControllableGenericSurface( + reference_area=0.005, + reference_length=0.05, + coefficients={ + "cl": lambda alpha, beta, mach, re, q, r, p, deflection: deflection + }, + name=name, + ) + + def test_surface_and_controller_wired(self, calisto_motorless): + surface = self.make_surface() + + def law(**kwargs): + kwargs["controlled_objects"].set_control("deflection", 0.2) + + controller = calisto_motorless.add_controllable_surface( + surface, -0.5, law, sampling_rate=20 + ) + assert isinstance(controller, SurfaceController) + assert controller.name == "Canards Controller" + assert controller in calisto_motorless._controllers + assert surface in [s for s, _ in calisto_motorless.aerodynamic_surfaces] + + controller(time=1.0, state=[0.0] * 13) + assert surface.get_control("deflection") == 0.2 + assert controller.control_history["Canards"]["deflection"] == [(1.0, 0.2)] + + def test_rejects_non_controllable_surface(self, calisto_motorless): + with pytest.raises(TypeError, match="ControllableGenericSurface"): + calisto_motorless.add_controllable_surface( + object(), -0.5, lambda **kwargs: None, sampling_rate=20 + ) + + +class TestRocketRoundTrip: + def test_rocket_with_air_brakes_round_trips(self, calisto_motorless): + add_air_brakes(calisto_motorless) + + encoded = json.dumps(calisto_motorless, cls=RocketPyEncoder) + loaded = json.loads(encoded, cls=RocketPyDecoder) + + # single air-brake instance, in both the surface loop and the list + assert len(loaded.air_brakes) == 1 + air_brakes = loaded.air_brakes[0] + assert air_brakes in [s for s, _ in loaded.aerodynamic_surfaces] + assert tuple(loaded.surfaces_cp_to_cdm[air_brakes]) == (0, 0, 0) + + # controller rewired to the decoded instance by name + assert len(loaded._controllers) == 1 + controller = loaded._controllers[0] + assert isinstance(controller, AirBrakesController) + assert controller.air_brakes is air_brakes + + controller(time=1.0, state=[0.0] * 13) + assert air_brakes.deployment_level == 0.5 + assert controller.control_history["air_brakes"]["deployment_level"] == [ + (1.0, 0.5) + ] + + def test_legacy_air_brakes_key_still_loads(self, calisto_motorless): + air_brakes, _ = add_air_brakes(calisto_motorless) + data = calisto_motorless.to_dict() + # simulate a legacy file: air brakes only under the "air_brakes" key + data["aerodynamic_surfaces"] = [ + (surface, position) + for surface, position in calisto_motorless.aerodynamic_surfaces + if surface is not air_brakes + ] + data["air_brakes"] = [air_brakes] + data["_controllers"] = [] + + loaded = type(calisto_motorless).from_dict(data) + assert loaded.air_brakes == [air_brakes] + assert air_brakes in [s for s, _ in loaded.aerodynamic_surfaces] + assert tuple(loaded.surfaces_cp_to_cdm[air_brakes]) == (0, 0, 0) diff --git a/tests/unit/rocket/test_stability_rework.py b/tests/unit/rocket/test_stability_rework.py new file mode 100644 index 000000000..e911b9feb --- /dev/null +++ b/tests/unit/rocket/test_stability_rework.py @@ -0,0 +1,93 @@ +"""Tests for the reworked stability model: the aerodynamic center, the +cp_position alias, the reconstructed nonlinear center of pressure, and the +aggregate aerodynamic coefficients.""" + +import numpy as np +import pytest + + +def test_cp_position_alias_matches_aerodynamic_center(calisto_robust): + """``cp_position`` is a plain alias of ``aerodynamic_center`` (no warning).""" + rocket = calisto_robust + assert rocket.cp_position.get_value_opt(0.3) == pytest.approx( + rocket.aerodynamic_center.get_value_opt(0.3) + ) + + +def test_reconstructed_center_of_pressure_converges_to_aerodynamic_center( + calisto_robust, +): + """The nonlinear center of pressure, reconstructed from the aggregate + coefficients as ``x_cdm + csys * d * Cm / CN``, converges to the linear + aerodynamic center as the angle of attack goes to zero. (The singular + nonlinear CP is no longer a blessed method; this is its documented + reconstruction path.)""" + rocket = calisto_robust + mach = 0.3 + aerodynamic_center = rocket.aerodynamic_center.get_value_opt(mach) + csys = rocket._csys + diameter = 2 * rocket.radius + cdm = rocket.center_of_dry_mass_position + + coeffs = rocket.aerodynamic_coefficients_full(np.radians(0.1), 0.0, mach) + reconstructed_cp = cdm + csys * diameter * coeffs["cm"] / coeffs["cL"] + assert reconstructed_cp == pytest.approx(aerodynamic_center, abs=1e-3) + + +def test_aerodynamic_coefficients_normal_force_grows_with_alpha(calisto_robust): + """Total normal-force coefficient increases with angle of attack and is zero + at zero incidence; the returned dict exposes normal force and pitch moment.""" + rocket = calisto_robust + coeffs = rocket.aerodynamic_coefficients(np.radians(5), 0.0, 0.3) + assert set(coeffs) == {"normal_force", "pitch_moment"} + + cn_2 = rocket.aerodynamic_coefficients(np.radians(2), 0.0, 0.3)["normal_force"] + cn_8 = rocket.aerodynamic_coefficients(np.radians(8), 0.0, 0.3)["normal_force"] + assert cn_8 > cn_2 > 0 + + +def test_axisymmetric_rocket_planes_coincide(calisto_robust): + """An axisymmetric rocket has matching pitch and yaw aerodynamic centers.""" + rocket = calisto_robust + assert rocket.is_axisymmetric + for mach in (0.0, 0.5, 1.0): + assert rocket.aerodynamic_center.get_value_opt(mach) == pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(mach) + ) + + +def test_aerodynamic_coefficients_full_signed_set(calisto_robust): + """The full rocket coefficient set returns all six signed coefficients; + lift grows with alpha, drag comes from the vehicle drag curve, and the pitch + moment is restoring (negative) for a stable rocket.""" + rocket = calisto_robust + coeffs = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3) + assert set(coeffs) == {"cL", "cQ", "cD", "cm", "cn", "cl"} + + low = rocket.aerodynamic_coefficients_full(np.radians(2), 0.0, 0.3) + assert coeffs["cL"] > low["cL"] > 0 + assert coeffs["cm"] < 0 # restoring pitch moment about the center of dry mass + assert coeffs["cD"] == pytest.approx( + rocket.power_off_drag_by_mach.get_value_opt(0.3) + ) + + +def test_add_vehicle_aerodynamic_surface(calisto_robust): + """A supplied full-vehicle coefficient set is added as a single generic + surface and contributes to the rocket aggregate (rocket-as-GenericSurface).""" + rocket = calisto_robust + base_cl = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cL"] + n_before = len(rocket.aerodynamic_surfaces) + + surface = rocket.add_vehicle_aerodynamic_surface( + coefficients={"cL": lambda a, b, m, re, p, q, r: 2.0 * a} + ) + + assert len(rocket.aerodynamic_surfaces) == n_before + 1 + # The vehicle surface exposes the uniform coefficient accessors. + assert surface.cL(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( + 2.0 * np.radians(5) + ) + # Its lift adds to the rocket aggregate. + new_cl = rocket.aerodynamic_coefficients_full(np.radians(5), 0.0, 0.3)["cL"] + assert new_cl > base_cl diff --git a/tests/unit/simulation/test_event_scheduler.py b/tests/unit/simulation/test_event_scheduler.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py index eacd2f3e1..0d42ee3a3 100644 --- a/tests/unit/simulation/test_flight.py +++ b/tests/unit/simulation/test_flight.py @@ -240,8 +240,8 @@ def test_export_sensor_data(flight_calisto_with_sensors): @pytest.mark.parametrize( "flight_time, expected_values", [ - ("t_initial", (0.25886, -0.649623, 0)), - ("out_of_rail_time", (0.792028, -1.987634, 0)), + ("t_initial", (-0.256474, -0.221748, 0)), + ("out_of_rail_time", (0.780787, -1.967135, 0)), ("apogee_time", (-0.509420, -0.732933, -2.089120e-14)), ("t_final", (0, 0, 0)), ], @@ -279,9 +279,9 @@ def test_aerodynamic_moments(flight_calisto_custom_wind, flight_time, expected_v @pytest.mark.parametrize( "flight_time, expected_values", [ - ("t_initial", (1.654150, 0.659142, -0.067103)), - ("out_of_rail_time", (5.052628, 2.013361, -1.75370)), - ("apogee_time", (2.321838, -1.613641, -0.962108)), + ("t_initial", (-0.062135, -1.936030, 1.612160)), + ("out_of_rail_time", (4.968766, 1.957238, -0.629070)), + ("apogee_time", (2.343357, -1.606424, -0.377026)), ("t_final", (-0.019802, 0.012030, 159.051604)), ], ) diff --git a/tests/unit/simulation/test_flight_derivatives_air_brakes.py b/tests/unit/simulation/test_flight_derivatives_air_brakes.py new file mode 100644 index 000000000..5d1ac080c --- /dev/null +++ b/tests/unit/simulation/test_flight_derivatives_air_brakes.py @@ -0,0 +1,99 @@ +"""Unit tests for the air-brakes handling in the flight derivatives, driven +without constructing a Flight: body-drag suppression under +override_rocket_drag and force equivalence of the surface-loop path with the +legacy drag-only formula.""" + +from types import SimpleNamespace + +import pytest + +from rocketpy import Function +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.simulation.helpers.flight_derivatives import _aerodynamic_drag_force + +DRAG_CURVE = "data/rockets/calisto/air_brakes_cd.csv" + +RHO = 1.10 +SPEED = 170.0 +MACH = 0.5 +REYNOLDS = 1e6 +OMEGA = (0.0, 0.0, 0.0) +TIME_AFTER_BURNOUT = 1000.0 + + +def body_drag(rocket): + flight = SimpleNamespace(rocket=rocket) + return _aerodynamic_drag_force( + flight, TIME_AFTER_BURNOUT, RHO, SPEED, 0, 0, MACH, REYNOLDS, OMEGA + ) + + +@pytest.fixture +def rocket_with_air_brakes(calisto_motorless): + air_brakes = calisto_motorless.add_air_brakes( + drag_coefficient_curve=DRAG_CURVE, + controller_function=lambda **kwargs: None, + sampling_rate=10, + ) + return calisto_motorless, air_brakes + + +class TestBodyDragSuppression: + def test_body_drag_unchanged_when_retracted(self, rocket_with_air_brakes): + rocket, air_brakes = rocket_with_air_brakes + air_brakes.deployment_level = 0 + reference = body_drag(rocket) + assert reference < 0 + + air_brakes.override_rocket_drag = True + assert body_drag(rocket) == reference + + def test_body_drag_unchanged_when_deployed_without_override( + self, rocket_with_air_brakes + ): + rocket, air_brakes = rocket_with_air_brakes + air_brakes.deployment_level = 0 + reference = body_drag(rocket) + air_brakes.deployment_level = 0.8 + assert body_drag(rocket) == reference + + def test_body_drag_suppressed_when_deployed_with_override( + self, rocket_with_air_brakes + ): + rocket, air_brakes = rocket_with_air_brakes + air_brakes.override_rocket_drag = True + air_brakes.deployment_level = 0.8 + assert body_drag(rocket) == 0.0 + + +class TestSurfaceLoopForceEquivalence: + def compute_surface_forces(self, rocket, air_brakes): + cp_to_cdm = rocket.surfaces_cp_to_cdm[air_brakes] + return air_brakes.compute_forces_and_moments( + Vector([0, 0, -SPEED]), + SPEED, + MACH, + RHO, + cp_to_cdm, + Vector([0, 0, 0]), + Function(lambda z: RHO), + Function(lambda z: 1.7e-5), + 1000.0, + ) + + def test_axial_force_matches_legacy_formula(self, rocket_with_air_brakes): + rocket, air_brakes = rocket_with_air_brakes + air_brakes.deployment_level = 0.5 + fx, fy, fz, m1, m2, m3 = self.compute_surface_forces(rocket, air_brakes) + + cd = air_brakes.drag_coefficient.get_value_opt(0.5, MACH) + legacy_force = -0.5 * RHO * SPEED**2 * air_brakes.reference_area * cd + assert fz == pytest.approx(legacy_force) + # zero incidence, pinned cp: no lateral force, no moments + assert (fx, fy, m1, m2, m3) == (0, 0, 0, 0, 0) + + def test_zero_force_when_retracted(self, rocket_with_air_brakes): + rocket, air_brakes = rocket_with_air_brakes + air_brakes.deployment_level = 0 + forces = self.compute_surface_forces(rocket, air_brakes) + assert tuple(forces) == (0, 0, 0, 0, 0, 0) diff --git a/tests/unit/stochastic/test_stochastic_rocket.py b/tests/unit/stochastic/test_stochastic_rocket.py index 8306b6039..2d4a6288a 100644 --- a/tests/unit/stochastic/test_stochastic_rocket.py +++ b/tests/unit/stochastic/test_stochastic_rocket.py @@ -1,3 +1,4 @@ +from rocketpy import AirBrakes, AirBrakesController from rocketpy.rocket.rocket import Rocket @@ -5,6 +6,40 @@ def test_str(stochastic_calisto): assert isinstance(str(stochastic_calisto), str) +def test_air_brakes_samples_are_isolated(stochastic_calisto): + """Each sampled rocket must get its own air brakes (wired into the + surface loop) and its own controller with a deep-copied context, so + Monte Carlo samples do not share mutable state.""" + air_brakes = AirBrakes( + drag_coefficient_curve="data/rockets/calisto/air_brakes_cd.csv", + reference_area=0.01, + ) + controller = AirBrakesController( + controller_function=lambda **kwargs: None, + air_brakes=air_brakes, + sampling_rate=10, + context={"observed_variables": []}, + ) + stochastic_calisto.add_air_brakes(air_brakes, controller) + + first = stochastic_calisto.create_object() + second = stochastic_calisto.create_object() + + # air brakes wired into the surface loop of each sample + for rocket in (first, second): + assert len(rocket.air_brakes) == 1 + assert rocket.air_brakes[0] in [ + surface for surface, _ in rocket.aerodynamic_surfaces + ] + assert isinstance(rocket._controllers[0], AirBrakesController) + assert rocket._controllers[0].air_brakes is rocket.air_brakes[0] + + # contexts are deep copies: mutating one sample touches nothing else + first._controllers[0].context["observed_variables"].append("junk") + assert second._controllers[0].context == {"observed_variables": []} + assert controller.context == {"observed_variables": []} + + def test_create_object(stochastic_calisto): """Test create object method of StochasticRocket class.