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:
objectLift, 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_directionaldistinguishes.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_directionaldistinguishes.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_directionaldistinguishes.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_directionaldistinguishes.
- 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:
PolarLoadsA 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.
OpenAVLLoadsVortex-lattice loads from openavl, differentiable with respect to the planform.
- class cdadt.models.loads.AerodynamicLoads[source]
Bases:
ABCA model that produces aerodynamic loads for a flight condition and a planform.
Subclasses implement
build(),coefficients()anddrag_gradients(). Everything else – installing the model in an OpenMDAO group, vectorising it over a mission, handing its drag to the trajectory – belongs tocdadt.adapterand 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.CLandCD0are 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_NAMESthis 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.
workspaceis 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 –PolarLoadsdoes. 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:
- classmethod new_workspace()[source]
Return a fresh store for whatever this model is expensive to recompute, or
None.The counterpart to the
workspaceargument ofbuild(). 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 tobuild()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:
- 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 fordrag = CD q Sitself, so what is wanted here is the derivative of the coefficient and nothing more.- Returns:
dict – Keyed by
GRADIENT_NAMES;area,AR,sweepandtaperare per unit of the wing number, in the unitsTrapezoidalPlanformuses – square metres for area and degrees for sweep.- Parameters:
condition (FlightCondition)
planform (Planform)
- 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:
condition (FlightCondition)
planform (Planform)
- Return type:
ndarray
- class cdadt.models.loads.FlightCondition(CL, mach, altitude=0.0, dynamic_pressure=0.0)[source]
Bases:
objectWhere 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:
ExceptionRaised when an aerodynamic loads model cannot produce what it was asked for.
- class cdadt.models.loads.Planform[source]
Bases:
ABCThe 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:
PlanformA 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 = 1steps to 1.000001 and threw. A constraint that breaks the derivative at its own boundary belongs in a case file’soptimizeband, 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) / 2for 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
selfbecause one caller cannot useself: 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 tofloat.- Parameters:
area (Any) – As the constructor takes them,
sweepin degrees. TypedAnyrather thanfloatdeliberately: these are floats under numpy and JAX tracers underjax.numpy, and narrowing them tofloatwould be a type annotation that lies about the second caller.aspect_ratio (Any) – As the constructor takes them,
sweepin degrees. TypedAnyrather thanfloatdeliberately: these are floats under numpy and JAX tracers underjax.numpy, and narrowing them tofloatwould be a type annotation that lies about the second caller.sweep (Any) – As the constructor takes them,
sweepin degrees. TypedAnyrather thanfloatdeliberately: these are floats under numpy and JAX tracers underjax.numpy, and narrowing them tofloatwould be a type annotation that lies about the second caller.taper (Any) – As the constructor takes them,
sweepin degrees. TypedAnyrather thanfloatdeliberately: these are floats under numpy and JAX tracers underjax.numpy, and narrowing them tofloatwould be a type annotation that lies about the second caller.arrays (module, optional) – The array library to evaluate in – anything providing
sqrt,tanandradians. Defaultnumpy. Injected rather than imported so that this module still imports neither dependency:cdadt.adapter.latticepassesjax.numpy.
- Returns:
dict –
span,semi_span,root_chord,tip_chord,mean_aerodynamic_chordandtip_leading_edge, each in whatever typearraysproduces.- 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|c4sweepmeans – 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:
objectOne 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
xaft,yto starboard andzup.- 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.
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:
AerodynamicLoadsDrag 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 fromac|aero|polar|e;OpenAVLLoadscomputes 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
eis 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 doesworkspace: an equation has nothing expensive to keep.- Parameters:
planform (Planform)
span_efficiency (float)
zero_lift_drag (object)
workspace (object)
- Return type:
- property span_efficiency: float
Oswald span efficiency.
- property zero_lift_drag: ndarray
Zero-lift drag coefficient, as given.
- induced_drag_factor(planform)[source]
Return
KinCD = CD0 + K CL^2, which is1 / (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_directionalsays.- Parameters:
condition (FlightCondition)
planform (Planform)
- Return type:
- 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,sweepandtaperare 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 makesOpenAVLLoadsreturning non-zero values there a difference in physics rather than a difference in interface.- Parameters:
condition (FlightCondition)
planform (Planform)
- Return type:
dict[str, ndarray]