Models

Physics cdadt owns, as distinct from physics it drives.

Everywhere else in cdadt, a class is an interface to the black box: a Discipline declares which variables its domain sets and which responses it reads, and computes nothing. This package is the exception, and the distinction is deliberate.

A model here is something cdadt computes and installs into the analysis. The mission still belongs to OpenConcept – the trajectory, the balanced field, the reserves, the weight closure – but the aerodynamic loads at every point of it can be cdadt’s own, which is what makes this a research framework rather than a driver.

Nothing in this package imports OpenConcept or openavl. It is OpenMDAO and numpy only, so the models are testable, differentiable and readable without either dependency present. The classes that bridge to a dependency live in cdadt.adapter, which is the only part of cdadt allowed to import one.

Because these components are real physics, they carry analytic derivatives. An optimizer steps on them and Verification publishes a derivative study; a finite-differenced partial here would be a silent loss of accuracy in every gradient the framework reports.

The six aerodynamic coefficients, and the names they are published under.

A loads model produces forces and moments in coefficient form. Six numbers describe them completely in the aircraft’s own axes, and every one is something a vortex-lattice method computes whether or not the caller asked: there is no saving in returning fewer.

The mission cdadt drives consumes only the drag today, because OpenConcept’s trajectory solves vertical equilibrium itself and asks the aircraft model for drag alone. The other five are carried anyway. Trim, static margin, control authority and the handling-qualities side of a certification basis are all written in terms of the moment coefficients, and a loads interface that discarded them would have to be widened later by changing every implementation.

class cdadt.models.coefficients.AeroCoefficients(CL, CD, CY=0.0, Cl=0.0, Cm=0.0, Cn=0.0)[source]

Bases: object

Lift, drag, sideforce and the three moment coefficients, at one or many flight points.

Every component is stored as an array so that a model evaluated over a whole mission phase and a model evaluated at a single point are the same object with different lengths.

Parameters:
  • CL (array_like) – Lift and drag coefficients. Required: no aerodynamic model produces neither.

  • CD (array_like) – Lift and drag coefficients. Required: no aerodynamic model produces neither.

  • CY (array_like, optional) – Sideforce, rolling-, pitching- and yawing-moment coefficients. Default zero, which is what a symmetric model in symmetric flight correctly reports – as distinct from “not computed”, which lateral_directional distinguishes.

  • Cl (array_like, optional) – Sideforce, rolling-, pitching- and yawing-moment coefficients. Default zero, which is what a symmetric model in symmetric flight correctly reports – as distinct from “not computed”, which lateral_directional distinguishes.

  • Cm (array_like, optional) – Sideforce, rolling-, pitching- and yawing-moment coefficients. Default zero, which is what a symmetric model in symmetric flight correctly reports – as distinct from “not computed”, which lateral_directional distinguishes.

  • Cn (array_like, optional) – Sideforce, rolling-, pitching- and yawing-moment coefficients. Default zero, which is what a symmetric model in symmetric flight correctly reports – as distinct from “not computed”, which lateral_directional distinguishes.

Raises:

ValueError – If the components do not all have the same length. A loads model that returned drag at every node and a moment at one has a bug that would otherwise surface as a broadcast deep inside a solver.

Examples

>>> coefficients = AeroCoefficients(CL=[0.5, 0.6], CD=[0.02, 0.024])
>>> coefficients.CD
array([0.02 , 0.024])
>>> len(coefficients)
2
>>> coefficients.lateral_directional
False
NAMES: ClassVar[tuple[str, ...]] = ('CL', 'CD', 'CY', 'Cl', 'Cm', 'Cn')

The components, in the order they are reported.

property CL: ndarray

Lift coefficient.

property CD: ndarray

Drag coefficient. The one the mission consumes.

property CY: ndarray

Sideforce coefficient.

property Cl: ndarray

Rolling-moment coefficient.

property Cm: ndarray

Pitching-moment coefficient.

property Cn: ndarray

Yawing-moment coefficient.

property lateral_directional: bool

Whether anything out of the symmetric plane is non-zero.

A symmetric aircraft in symmetric flight genuinely has zero sideforce and zero rolling and yawing moment, so zero is an answer rather than an absence. This says which case a reader is looking at without having to compare six arrays by eye.

The aerodynamic loads abstraction: the slot a model plugs into.

Shaped after falco’s falco.core.loads.loads.Loads, which declares one abstract method turning a flight state into forces and moments. The same idea here, in coefficient form, because that is what a vortex-lattice method returns and what OpenConcept’s mission asks for.

How a model reaches the mission

A model never talks to OpenConcept. It is handed a FlightCondition and a Planform and returns AeroCoefficients; cdadt.adapter is what installs it into an OpenMDAO group and hands its drag coefficient to the trajectory. So a new model is a new subclass and nothing else moves – and a model can be written, differentiated and tested with neither OpenConcept nor openavl installed.

Two implementations are shipped:

PolarLoads

A parabolic drag polar. Deliberately the same equation OpenConcept uses, which is what lets it reproduce the reference exactly and prove the surrounding machinery is faithful before any new physics rides on it.

OpenAVLLoads

Vortex-lattice loads from openavl, differentiable with respect to the planform.

class cdadt.models.loads.AerodynamicLoads[source]

Bases: ABC

A model that produces aerodynamic loads for a flight condition and a planform.

Subclasses implement build(), coefficients() and drag_gradients(). Everything else – installing the model in an OpenMDAO group, vectorising it over a mission, handing its drag to the trajectory – belongs to cdadt.adapter and is not a model’s concern.

Variables:

model_name (str) – Short identifier, used in reports and in the run record. Abstract in the same sense as a discipline’s name: it is the one thing no model can inherit.

model_name: ClassVar[str]
GRADIENT_NAMES: ClassVar[tuple[str, ...]] = ('CL', 'CD0', 'e', 'area', 'AR', 'sweep', 'taper')

The names drag_gradients() must answer for. CL and CD0 are the flight-point quantities; the rest are the wing numbers a case file declares. A model that a variable genuinely does not reach still reports zero for it – explicitly, so that “this does not affect my drag” is a statement the model makes rather than an omission the wrapper guesses.

DRAG_DEPENDS_ON: ClassVar[tuple[str, ...]] = ('CL', 'CD0', 'e', 'area', 'AR', 'sweep', 'taper')

Which of GRADIENT_NAMES this model’s drag coefficient can actually depend on. A subclass narrows it to say so structurally, which is what lets the wrapper declare a truthful sparsity pattern instead of claiming a dependence it then reports as zero. The default is everything, because over-declaring is merely wasteful whereas under-declaring is wrong.

abstractmethod classmethod build(*, planform, span_efficiency, zero_lift_drag, workspace=None)[source]

Construct this model from the black box’s current values.

Every model is offered the same three things and takes what it needs – a parabolic polar wants the span efficiency and ignores the wing’s shape; a vortex lattice wants the shape and computes the efficiency itself. Declaring that choice explicitly is why this is abstract rather than a shared constructor signature: what a model consumes is part of what it is.

Called on every evaluation, with values as they currently stand, because the span efficiency and the zero-lift drag are variables of the black box rather than configuration. Construction must therefore be cheap.

workspace is how a model that cannot be cheap stays cheap. A vortex lattice costs tens of seconds per geometry and must not pay that per Newton iteration, so it needs somewhere to keep solved results that outlives one evaluation. The obvious answer – a module-level cache – is a global by another name, and the brief forbids those; so the caller owns the store and injects it, which puts its lifetime where the decision belongs. The analysis group creates one per study and threads it down, so every phase of a mission shares one.

A model must work with workspace=None, which means “no store offered, solve what you need”. A model that has nothing expensive to keep ignores the argument entirely – PolarLoads does. What the object is is the model’s business: the interface promises only that it is handed back unchanged on every call.

Parameters:
  • planform (Planform)

  • span_efficiency (float)

  • zero_lift_drag (object)

  • workspace (object)

Return type:

AerodynamicLoads

classmethod new_workspace()[source]

Return a fresh store for whatever this model is expensive to recompute, or None.

The counterpart to the workspace argument of build(). That argument settles who owns the lifetime – the caller, so that one store serves a whole study rather than one evaluation. This settles what the store is, which is the model’s business and nothing else’s: a caller that had to know would have to know which solver sits underneath, and the whole point of the slot is that it does not.

Default None, for a model with nothing expensive to keep. A caller creates one of these per study and hands it back to build() on every evaluation.

This exists because the alternative failed in exactly the way that matters. The analysis group used to construct the store itself, which silently meant openavl’s store – so naming the OpenAeroStruct model in a case file handed it a library full of another code’s answers for the same wing. The two differ by about 4%, so nothing would have raised had the library not refused it by name.

Return type:

object | None

abstractmethod coefficients(condition, planform)[source]

Return the aerodynamic coefficients at every point of condition.

Parameters:
  • condition (FlightCondition) – Where the aircraft is and what lift it is producing.

  • planform (Planform) – The wing being evaluated.

Returns:

AeroCoefficients – Six components, at the same number of points as condition.

Return type:

AeroCoefficients

abstractmethod drag_gradients(condition, planform)[source]

Return the derivatives of the drag coefficient, one entry per GRADIENT_NAMES.

Abstract because an optimizer steps on these. A default implementation could only be a finite difference or a zero, and either would let a model ship with derivatives that are quietly not its own – which is the failure this framework exists to avoid.

Each value has the same shape as condition.CL. The wrapper that installs the model applies the product rule for drag = CD q S itself, so what is wanted here is the derivative of the coefficient and nothing more.

Returns:

dict – Keyed by GRADIENT_NAMES; area, AR, sweep and taper are per unit of the wing number, in the units TrapezoidalPlanform uses – square metres for area and degrees for sweep.

Parameters:
Return type:

dict[str, ndarray]

drag(condition, planform)[source]

Return the drag force in newtons, which is what the trajectory consumes.

Not abstract: it is coefficients() times dynamic pressure and area, and a model that overrode it could report a drag inconsistent with its own drag coefficient.

Parameters:
Return type:

ndarray

class cdadt.models.loads.FlightCondition(CL, mach, altitude=0.0, dynamic_pressure=0.0)[source]

Bases: object

Where the aircraft is and what it is doing, at one or many points along a mission.

The quantities a loads model is entitled to ask about. Every one is published by OpenConcept’s mission at every node under a fltcond| name, so a model can be evaluated over a whole phase at once.

Parameters:
  • CL (array_like) – Lift coefficient. Supplied to the model rather than computed by it: the trajectory solves vertical equilibrium and tells the aircraft what lift it is producing.

  • mach (array_like) – Flight Mach number.

  • altitude (array_like, optional) – Geometric altitude in metres. Default 0.

  • dynamic_pressure (array_like, optional) – Dynamic pressure in pascals. Default 0. Needed to turn a coefficient into a force.

Raises:

ValueError – If the quantities are given at different numbers of points.

NAMES: ClassVar[tuple[str, ...]] = ('CL', 'mach', 'altitude', 'dynamic_pressure')
property CL: ndarray

Lift coefficient the trajectory is asking the aircraft to produce.

property mach: ndarray

Flight Mach number.

property altitude: ndarray

Geometric altitude, metres.

property dynamic_pressure: ndarray

Dynamic pressure, pascals.

exception cdadt.models.loads.LoadsError[source]

Bases: Exception

Raised when an aerodynamic loads model cannot produce what it was asked for.

class cdadt.models.loads.Planform[source]

Bases: ABC

The wing shape a loads model is evaluated on.

Abstract because what a model needs from the geometry depends on the model. A parabolic polar needs area and aspect ratio; a vortex-lattice method needs the actual lifting surface. Both are described here by the same four numbers a case file declares, and it is the implementation’s business to turn them into whatever it solves on.

abstract property area: float

Reference wing area, square metres.

abstract property aspect_ratio: float

Wing aspect ratio.

The trapezoidal wing a case file describes, and the lifting surface it implies.

A case file declares a wing the way a designer states one: reference area, aspect ratio, quarter chord sweep and taper ratio. A vortex-lattice method needs something else entirely – the leading-edge coordinates and chord of every section. This module is the translation, and it is cdadt’s own geometry rather than anyone else’s physics.

The same four numbers serve both models cdadt ships. PolarLoads reads only area and aspect_ratio; OpenAVLLoads builds a surface from TrapezoidalPlanform.sections(). That is the point of Planform being abstract in what it exposes.

class cdadt.models.planform.TrapezoidalPlanform(area, aspect_ratio, sweep=0.0, taper=1.0)[source]

Bases: Planform

A straight-tapered wing built from the four numbers a case file declares.

Parameters:
  • area (float) – Reference area, square metres.

  • aspect_ratio (float) – Aspect ratio, span squared over area.

  • sweep (float, optional) – Quarter-chord sweep, degrees. Default 0. Degrees because that is the unit the case file and 14 CFR both use; it is converted once, here.

  • taper (float, optional) – Tip chord over root chord. Default 1, an untapered wing. Values above 1 describe an inverse-taper wing, which is unusual but perfectly buildable, so they are allowed.

Raises:

ValueError – If the area, aspect ratio or taper is not positive. Zero taper is a wing with no tip chord, which no lattice can be built on. Note what is not rejected: a taper above 1. An earlier version capped it there, on the reasoning that a tapered wing narrows outboard. That made the class non-differentiable at exactly the bound an optimizer is most likely to sit on – a finite difference at taper = 1 steps to 1.000001 and threw. A constraint that breaks the derivative at its own boundary belongs in a case file’s optimize band, not in a geometry class.

Examples

>>> wing = TrapezoidalPlanform(area=124.6, aspect_ratio=9.45, sweep=25.0, taper=0.159)
>>> round(wing.span, 3)
34.31
>>> round(wing.root_chord, 3)
6.267
property area: float

Reference wing area, square metres.

property aspect_ratio: float

Wing aspect ratio.

property sweep: float

Quarter-chord sweep, degrees.

property taper: float

Tip chord over root chord.

property span: float

Wing span, metres.

property root_chord: float

Centreline chord, metres.

From S = b * c_root * (1 + taper) / 2 for a straight-tapered wing.

property tip_chord: float

Chord at the tip, metres.

property mean_aerodynamic_chord: float

Mean aerodynamic chord, metres.

The standard trapezoidal result, (2/3) c_root (1 + t + t^2) / (1 + t).

static geometry(area, aspect_ratio, sweep, taper, arrays=<module 'numpy' from '/opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/numpy/__init__.py'>)[source]

Return the trapezoid’s derived dimensions, in whichever array library is passed in.

The single statement of these formulas. It is a static method taking the four numbers rather than reading self because one caller cannot use self: the vortex-lattice model differentiates the lattice with respect to these four numbers, so it needs them evaluated on JAX tracers, and a tracer cannot be stored in a class whose constructor casts to float.

Parameters:
  • area (Any) – As the constructor takes them, sweep in degrees. Typed Any rather than float deliberately: these are floats under numpy and JAX tracers under jax.numpy, and narrowing them to float would be a type annotation that lies about the second caller.

  • aspect_ratio (Any) – As the constructor takes them, sweep in degrees. Typed Any rather than float deliberately: these are floats under numpy and JAX tracers under jax.numpy, and narrowing them to float would be a type annotation that lies about the second caller.

  • sweep (Any) – As the constructor takes them, sweep in degrees. Typed Any rather than float deliberately: these are floats under numpy and JAX tracers under jax.numpy, and narrowing them to float would be a type annotation that lies about the second caller.

  • taper (Any) – As the constructor takes them, sweep in degrees. Typed Any rather than float deliberately: these are floats under numpy and JAX tracers under jax.numpy, and narrowing them to float would be a type annotation that lies about the second caller.

  • arrays (module, optional) – The array library to evaluate in – anything providing sqrt, tan and radians. Default numpy. Injected rather than imported so that this module still imports neither dependency: cdadt.adapter.lattice passes jax.numpy.

Returns:

dictspan, semi_span, root_chord, tip_chord, mean_aerodynamic_chord and tip_leading_edge, each in whatever type arrays produces.

Return type:

dict[str, Any]

Notes

The tip leading edge is placed so that the quarter chord carries the stated sweep, which is what ac|geom|wing|c4sweep means – sweeping the leading edge instead would give a different wing for the same case file.

sections()[source]

Return the root and tip sections of the starboard semi-span.

Two sections define a trapezoid completely, and a lattice method panels between them.

Return type:

tuple[WingSection, …]

class cdadt.models.planform.WingSection(x, y, z, chord)[source]

Bases: object

One spanwise station of a lifting surface: where its leading edge is, and its chord.

The description a vortex-lattice method works in. Coordinates are in the aircraft’s own frame, metres, with x aft, y to starboard and z up.

Parameters:
  • x (float)

  • y (float)

  • z (float)

  • chord (float)

property x: float

Leading-edge station, metres aft.

property y: float

Spanwise station, metres from the centreline.

property z: float

Leading-edge height, metres.

property chord: float

Section chord, metres.

The parabolic drag polar: the model that proves the machinery before the physics changes.

\[C_D = C_{D_0} + \frac{C_L^2}{\pi e A\!R}\]

This is deliberately the same equation OpenConcept’s PolarDrag evaluates, and that is the point of it. Everything around a loads model in cdadt is new – the abstraction, the aircraft model, the analysis group that installs it – and all of it has to be shown faithful before a genuinely different aerodynamic model rides on top. A model that reproduces the reference exactly turns “the answer changed” into evidence about one thing at a time.

It is not a stub. It is the classical polar, with analytic derivatives, and it remains the cheapest useful model in the framework: OpenAVLLoads computes the span efficiency this model takes as an input.

class cdadt.models.polar.PolarLoads(span_efficiency, zero_lift_drag=0.0)[source]

Bases: AerodynamicLoads

Drag from a parabolic polar, given a zero-lift drag coefficient and a span efficiency.

Parameters:
  • span_efficiency (float) – Oswald span efficiency e. In the shipped case this comes from ac|aero|polar|e; OpenAVLLoads computes it from the lattice instead.

  • zero_lift_drag (array_like, optional) – CD0, either one value or one per flight point. Default 0, which makes this a pure induced-drag model – useful on its own, because the induced drag is the part that depends on the planform being optimized.

Raises:

LoadsError – If the span efficiency is not positive. A zero or negative e is a division by zero dressed as a configuration value.

Examples

>>> from cdadt.models.planform import TrapezoidalPlanform
>>> loads = PolarLoads(span_efficiency=0.82, zero_lift_drag=0.02)
>>> wing = TrapezoidalPlanform(area=124.6, aspect_ratio=9.45)
>>> condition = FlightCondition(CL=0.5, mach=0.78)
>>> round(float(loads.coefficients(condition, wing).CD), 6)
0.030246
model_name: ClassVar[str] = 'parabolic_polar'
DRAG_DEPENDS_ON: ClassVar[tuple[str, ...]] = ('CL', 'CD0', 'e', 'AR')

The equation has four variables and no more. Sweep, taper and area are absent from it, so the wrapper that installs this model declares no partials against them rather than declaring them and reporting zero.

classmethod build(*, planform, span_efficiency, zero_lift_drag, workspace=None)[source]

Build from the span efficiency and zero-lift drag; the wing’s shape is not used.

A parabolic polar sees the wing only through its aspect ratio, which reaches it via the planform passed to coefficients(). The shape – sweep, taper, the sections – makes no difference to this model, and saying so here is more honest than accepting it and quietly ignoring it. Nor does workspace: an equation has nothing expensive to keep.

Parameters:
  • planform (Planform)

  • span_efficiency (float)

  • zero_lift_drag (object)

  • workspace (object)

Return type:

PolarLoads

property span_efficiency: float

Oswald span efficiency.

property zero_lift_drag: ndarray

Zero-lift drag coefficient, as given.

induced_drag_factor(planform)[source]

Return K in CD = CD0 + K CL^2, which is 1 / (pi e AR).

Parameters:

planform (Planform)

Return type:

float

coefficients(condition, planform)[source]

Return the coefficients at every point of condition.

Only lift and drag are non-zero. A parabolic polar carries no information about sideforce or moments, and reporting a fabricated zero moment would be a different claim from reporting that the model does not produce one – which is what lateral_directional says.

Parameters:
Return type:

AeroCoefficients

drag_gradients(condition, planform)[source]

Return the analytic derivatives of CD, for the component that installs this model.

Written here rather than in the OpenMDAO wrapper because they belong to the equation, and an optimizer steps on them. Keys are the names the wrapper declares partials against.

area, sweep and taper are zero, and that is the equation speaking rather than a gap: this polar sees the wing through its aspect ratio alone. Reporting them explicitly is what lets the wrapper declare the same partials whatever model is installed, and what makes OpenAVLLoads returning non-zero values there a difference in physics rather than a difference in interface.

Parameters:
Return type:

dict[str, ndarray]