Adapter
The only part of cdadt that imports a dependency.
Everywhere else, cdadt names what it drives and loads it at run time, so the boundary cannot erode. That worked while cdadt computed nothing. Installing cdadt’s own aerodynamics into OpenConcept’s mission is different: an aircraft model must be an OpenMDAO group that OpenConcept instantiates, and building one means composing OpenConcept’s propulsion and weight blocks around cdadt’s loads.
So exactly one place is allowed to import OpenConcept and openavl, and it is this package. That is what the project brief asks for – “The wrapper should be the only part of CDADT that directly imports OpenConcept” – and a contract test enforces it by allow-listing this package and no other.
What lives here
cdadt.adapter.loadsThe OpenMDAO component that evaluates a
AerodynamicLoadsover a phase and publishesdrag.cdadt.adapter.aircraftThe per-phase aircraft model OpenConcept’s mission instantiates: cdadt’s loads, OpenConcept’s engine deck, fuel integrator and weight bookkeeping.
cdadt.adapter.analysisThe sizing analysis group that installs the aircraft model into
FullMissionWithReserve– the thing a case file names as its black box.cdadt.adapter.latticeEvery openavl call cdadt makes: the differentiable vortex lattice, the polar fitted from it, and the exact geometry Jacobian.
cdadt.adapter.avlcdadt.adapter.latticepresented as anAerodynamicLoads, so a case file can name it.
The wiring and the physics are still separated, but the line falls inside this package rather than
at its edge. loads, aircraft and
analysis compute nothing; the two lattice modules do, because driving openavl
means importing it, and importing a dependency is only allowed here. Physics that needs neither
dependency belongs in cdadt.models, which is where the abstraction and the parabolic polar
live and why they can be tested without either.
The component that evaluates a cdadt loads model at every node of a mission phase.
This is the join between two worlds. On one side is
AerodynamicLoads: plain Python, numpy, no dependency, testable on
its own. On the other is OpenMDAO, which wants a component with declared inputs, outputs and
partials, instantiated inside a group it controls.
The component owns no physics. It reads the black box’s flight conditions and geometry, hands
them to the model as a FlightCondition and a
TrapezoidalPlanform, and publishes the drag force the trajectory
consumes. Swapping the model swaps the aerodynamics and changes nothing else.
Notes
The model is constructed once per compute(), from the input values as
they stand. That is deliberate – the span efficiency and the zero-lift drag are variables of
the black box, not configuration, and a model holding stale copies of them would silently report
the drag of a design the optimizer has already moved away from. It also means model
construction must be cheap: it happens at every Newton iteration.
A model whose construction is not cheap keeps the expensive part in the workspace its caller
injects, as OpenAVLLoads does. This component does not own that
workspace and does not know what is in it – it is created by the analysis group, whose lifetime is
a study, and passed straight through. That is deliberate: a store owned by this component would be
re-created for each of the fourteen phases, and a store at module scope would be the global the
project’s brief forbids.
- class cdadt.adapter.loads.AerodynamicLoadsComp(**kwargs)[source]
Bases:
ExplicitComponentEvaluate an aerodynamic loads model over a phase and publish the drag force.
Options
- num_nodesint
Analysis points in the phase.
- loads_factorytype[AerodynamicLoads]
The model class to install. Its
build()is called with the planform, the span efficiency and the zero-lift drag as they currently stand; a class rather than an instance because those are black-box variables that move under the optimizer, and because the class states – throughDRAG_DEPENDS_ON– which partials this component should declare.- workspaceobject, optional
Passed to the model’s
buildunread and unexamined. Somewhere for a model to keep results too expensive to recompute per evaluation; seebuild().
Notes
Only
dragis published. The model computes all six coefficients, but the mission consumes the force alone, and an output nothing connects to is noise in an N2 diagram of 826 of them. The rest are reachable through the model itself when a study wants them.- SCALAR_INPUTS: ClassVar[Mapping[str, str]] = mappingproxy({'ac|geom|wing|S_ref': 'area', 'ac|geom|wing|AR': 'AR', 'ac|geom|wing|c4sweep': 'sweep', 'ac|geom|wing|taper': 'taper', 'ac|aero|polar|e': 'e'})
The black-box variables that hold for the whole phase, mapped to the gradient name the loads model answers under. Everything else the component reads varies node by node.
- initialize()[source]
Declare the options that make this component specific to a phase and a model.
- Return type:
None
- setup()[source]
Declare the flight conditions and geometry read, and the drag published.
- Return type:
None
- compute(inputs, outputs)[source]
Evaluate the loads model and publish the drag force.
- Parameters:
inputs (Any)
outputs (Any)
- Return type:
None
- compute_partials(inputs, partials)[source]
Publish analytic derivatives, taken from the model rather than differenced.
The model supplies the gradients of its own drag coefficient; the product rule for
drag = CD q Sbelongs here, becauseqandSare the component’s business and not the model’s.The reference area appears twice and both terms are kept. It scales the coefficient into a force, which every model shares, and it may also change the coefficient itself, which is a model’s own business – a vortex lattice re-normalises by the new area, a parabolic polar does not. Dropping the second term would silently be right for one model and wrong for the other.
- Parameters:
inputs (Any)
partials (Any)
- Return type:
None
The wing’s sections, published as OpenMDAO variables so OpenConcept’s components can read them.
TrapezoidalPlanform already turns the four numbers a case file
declares into spanwise stations and chords. This is that translation as a component, because one of
the things cdadt installs – OpenConcept’s own WaveDragFromSections – asks for the wing
section by section rather than as an area and an aspect ratio.
It is in cdadt.adapter rather than in cdadt.models for the usual reason: a model is
plain Python and a component is OpenMDAO. It computes nothing of its own – the formulas live once,
in the planform.
Section ordering
WaveDragFromSections wants sections “starting with the outboard section (wing tip) at the MOST
NEGATIVE y value and moving inboard”, and it wants y_sec for every section except the root,
because the root is always zero. Those two conventions are its own, they are easy to get backwards,
and getting them backwards would silently describe a wing with the tip chord at the root. So they
are honoured here explicitly and checked by a test that reads the tip chord back out.
- class cdadt.adapter.sections.WingSectionsComp(**kwargs)[source]
Bases:
ExplicitComponentPublish a trapezoidal wing’s spanwise stations and chords from the four numbers it is given.
Inputs are
ac|geom|wing|S_ref,ac|geom|wing|AR,ac|geom|wing|taperandac|geom|wing|toverc; outputs arey_sec(the tip station, negative, one value because the root’s is zero by convention),chord_sec(tip then root) andtoverc_sec.The thickness ratio is broadcast rather than distributed. A case file describes a trapezoidal wing with a single
ac|geom|wing|toverc, so giving the tip a different value would be inventing geometry the study never stated. It is published here rather than assembled with anExecCompin the aircraft model because a section quantity belongs with the other section quantities, and because one component with analytic partials is easier to check than two.Derivatives are analytic and closed-form. Writing the root chord as
\[c_\mathrm{root} = \frac{2\sqrt{S}}{\sqrt{A\!R}\,(1 + \lambda)}\]makes every derivative a multiple of the quantity itself, which is why they are one-liners rather than an application of the quotient rule to \(2S / (b(1+\lambda))\).
- initialize()[source]
Declare nothing: a wing has one shape however many nodes a phase has.
- Return type:
None
- setup()[source]
Declare the three wing numbers read and the two section arrays published.
- Return type:
None
The per-phase aircraft model OpenConcept’s mission instantiates.
OpenConcept’s mission profiles take the aircraft model as an option – aircraft_model,
documented as “OpenConcept-compliant airplane model” – and instantiate it once per phase with
promotes_inputs=["*"], promotes_outputs=["*"]. The contract is therefore simple and total:
given the phase’s flight conditions, throttle and the aircraft’s ac| variables, publish
drag, thrust, fuel_flow and weight.
This class is that contract with cdadt’s aerodynamics in it. Everything else is
OpenConcept’s, used as published and unmodified: the rubberized CFM56 deck, the engine-count
bookkeeping that handles an engine failure, Simpson-rule fuel integration, and the weight that
falls out of it. Only the drag comes from cdadt.models.
Why this class exists at all
B738_sizing.py hardcodes aircraft_model=B738AircraftModel at the point it builds the
mission, so the aerodynamics cannot be substituted from outside it. Owning the aircraft model is
the smallest change that makes the substitution possible, and it needs no modification to
OpenConcept whatsoever – openconcept/examples/B738_VLM_drag.py does the same thing to swap in
a vortex-lattice drag model.
- class cdadt.adapter.aircraft.CdadtAircraftModel(**kwargs)[source]
Bases:
GroupOne flight phase of an aircraft whose aerodynamics belong to cdadt.
Options
- num_nodesint
Analysis points in this phase.
- flight_phasestr
Which phase this is. Only the takeoff phases are distinguished, because the zero-lift drag buildup is configured for takeoff flaps there.
- loads_factorycallable
Builds the
AerodynamicLoadsthis aircraft flies with. Passed down from the analysis group, which reads it from the case file.- zero_lift_drag_buildercallable or None
Called with
(num_nodes, in_takeoff)to build the component supplyingCD0.Nonemeans the aircraft has no separate parasite buildup and the loads model produces the whole drag coefficient itself.- wave_dragbool
Add OpenConcept’s own transonic wave drag on top of the parasite buildup. Default
False, and the default is load-bearing: OpenConcept’sB738AircraftModelhas no wave drag, so an aircraft that added it unasked could not reproduce the reference example, and the parity anchor that makes every other comparison meaningful would be gone. Requires OpenAeroStruct, which is where OpenConcept keeps the component.
- TAKEOFF_PHASES: tuple[str, ...] = ('v0v1', 'v1v0', 'v1vr', 'rotate')
Phases that run along the ground, where the flaps are down.
The sizing analysis a case file names, with cdadt’s aerodynamics installed in it.
This is the group that makes the substitution possible. B738_sizing.py builds its mission
with aircraft_model=B738AircraftModel hardcoded, so no amount of configuration from outside
can change the aerodynamics. Owning the analysis group – and therefore the line that installs
the aircraft model – is the whole of what it takes.
Everything except the aerodynamics is OpenConcept’s, composed as its own example composes it:
the trapezoidal wing geometry, the tail volume-coefficient sizing, the wetted areas, the maximum
lift estimates, the jet-transport empty-weight buildup, the weight closure, and
FullMissionWithReserve – the balanced-field takeoff, climb, cruise, descent, the Part 25
reserve diversion and the loiter.
What differs from OpenConcept’s own group
Two things, both deliberate.
The aeroplane comes from the case file. OpenConcept’s group reads its aircraft from a Python data dictionary shipped inside the library. cdadt declares the same variables as plain independent variables and lets the case file supply every value, which is what makes a study a file rather than a source edit – and what keeps this group from being a copy of a data table cdadt does not own.
The aerodynamics is a parameter. The loads model is named in the case file and resolved to a class, so a study can fly the same aeroplane through a parabolic polar or a vortex lattice by changing one line.
- class cdadt.adapter.analysis.SizingMissionAnalysis(**kwargs)[source]
Bases:
GroupA full-mission sizing analysis whose aerodynamics are supplied by cdadt.
Options
- num_nodesint
Analysis points per mission phase. Must be odd; the fuel burn is integrated with Simpson’s rule.
- aerodynamic_loadsstr
"module:ClassName"naming theAerodynamicLoadsto fly with. Defaults to the parabolic polar, which reproduces what OpenConcept’s own group computes.- wave_dragbool
Add OpenConcept’s own Korn-equation transonic drag rise. Default
False, so that the default analysis is the one that reproduces the reference example; a study that cares about the transonic edge of the envelope turns it on. Requires OpenAeroStruct.
Notes
The loads model is resolved from a string for the same reason the black box itself is: a study should be able to change the aerodynamics without editing Python. See
ClassSpec.- AIRCRAFT_VARIABLES: tuple[tuple[str, str | None], ...] = (('ac|aero|polar|e', None), ('ac|aero|Mach_max', None), ('ac|aero|Vstall_land', 'm/s'), ('ac|aero|airfoil_Cl_max', None), ('ac|aero|takeoff_flap_deg', 'deg'), ('ac|propulsion|engine|rating', 'lbf'), ('ac|propulsion|num_engines', None), ('ac|geom|wing|S_ref', 'm**2'), ('ac|geom|wing|AR', None), ('ac|geom|wing|c4sweep', 'deg'), ('ac|geom|wing|taper', None), ('ac|geom|wing|toverc', None), ('ac|geom|hstab|AR', None), ('ac|geom|hstab|c4sweep', 'deg'), ('ac|geom|hstab|taper', None), ('ac|geom|hstab|toverc', None), ('ac|geom|vstab|AR', None), ('ac|geom|vstab|c4sweep', 'deg'), ('ac|geom|vstab|taper', None), ('ac|geom|vstab|toverc', None), ('ac|geom|fuselage|length', 'm'), ('ac|geom|fuselage|height', 'm'), ('ac|geom|nacelle|length', 'm'), ('ac|geom|nacelle|diameter', 'm'), ('ac|geom|maingear|length', 'm'), ('ac|geom|maingear|num_wheels', None), ('ac|geom|maingear|num_shock_struts', None), ('ac|geom|nosegear|length', 'm'), ('ac|geom|nosegear|num_wheels', None), ('ac|weights|W_payload', 'kg'), ('ac|num_passengers_max', None), ('ac|num_flight_deck_crew', None), ('ac|num_cabin_crew', None), ('ac|cabin_pressure', 'psi'))
The aircraft variables this group accepts, with the units they are declared in. The case file supplies every value; these are the names and the dimensions, not the aeroplane.
- cdadt.adapter.analysis.jet_transport_zero_lift_drag(num_nodes, in_takeoff)[source]
Return OpenConcept’s parasite drag buildup, configured for the phase.
The zero-lift drag is left with OpenConcept on purpose. It is a component-by-component empirical buildup – skin friction, form factors, flap drag, wetted areas – and reimplementing it in cdadt would be copying six hundred lines of somebody else’s correlations, which the project’s rules forbid and which would prove nothing.
What cdadt owns is what the case file can change: how that zero-lift drag combines with the lift-dependent drag its own model computes.
- Parameters:
num_nodes (int)
in_takeoff (bool)
- Return type:
Group
Aerodynamic loads from openavl: a vortex lattice standing in for OpenConcept’s drag polar.
This is the model the whole abstraction exists for. The wing the case file describes is built as an AVL lattice, solved, and the lift-dependent drag it predicts replaces the one OpenConcept assumes.
Why the lattice is not solved at every node
A sizing mission is about 170 analysis points, inside a Newton solve, inside an optimizer.
openavl’s OpenMDAO component evaluates one flight point per instance, so solving per node would
mean thousands of lattice solves per design. OpenConcept faced exactly this and answered it with
a trained surrogate – VLMDragPolar exists because “a surrogate model to decrease the
computational cost” was necessary.
cdadt takes the cheaper and exact route, because a vortex lattice is linear. For a fixed geometry the far-field drag is quadratic in the far-field lift, so three solves determine the whole polar:
The quadratic is an identity rather than a curve fit, and it is written with an offset rather than as \(C_D \propto C_L^2\) because twist and camber move the minimum-drag point away from zero lift. Three solves per geometry and Mach number, then every node in closed form – and the polar is re-derived whenever the planform moves, which is what keeps this exact under an optimizer rather than a surrogate that drifts.
cdadt.adapter.lattice owns the openavl calls, the far-field pairing that makes the polar
self-consistent with openavl’s own span efficiency, and the exact geometry Jacobian. This module is
what turns those into an AerodynamicLoads.
- cdadt.adapter.avl.MACH_SAMPLES: tuple[float, ...] = (0.0, 0.3, 0.5, 0.7, 0.85)
Mach numbers the polar is fitted at. The shipped mission runs from roughly M 0.35 in the climb to 0.785 in cruise, and the lattice’s answer varies smoothly and weakly across that: the induced drag at CL = 0.5 moves 0.8% between M = 0 and M = 0.785, and the span efficiency from 0.990 to 0.998. Five points with linear interpolation is therefore well inside the lattice’s own resolution error, and costs fifteen solves per geometry instead of three.
- class cdadt.adapter.avl.OpenAVLLoads(planform, zero_lift_drag=0.0, chordwise=6, spanwise=20, library=None)[source]
Bases:
AerodynamicLoadsLift-dependent drag from an openavl vortex lattice, with the polar fitted per geometry.
- Parameters:
planform (Planform) – The wing to build the lattice on. Must be a
TrapezoidalPlanform; the lattice is built from its sections.zero_lift_drag (array_like, optional) – Parasite
CD0from outside the lattice – the fuselage, nacelles, tails and skin friction that a wing-only lattice does not see. Default 0. In a cdadt study this is OpenConcept’s own component buildup, which is left where it is because reimplementing it would be copying six hundred lines of correlations.chordwise (int, optional) – Lattice density. Defaults are deliberately modest; the polar is fitted once per geometry and Mach sample, so the cost is bounded whatever the mission length.
spanwise (int, optional) – Lattice density. Defaults are deliberately modest; the polar is fitted once per geometry and Mach sample, so the cost is bounded whatever the mission length.
library (LatticeLibrary | None)
- Raises:
LoadsError – If openavl is not installed, or if the planform is not one this model can build a lattice from, or if the lattice returns a polar that curves the wrong way.
Notes
Wave drag is not this model’s business. A vortex lattice has no mechanism for it; what Mach reaches here is openavl’s Prandtl-Glauert correction, through
MACH_SAMPLES. Transonic drag rise comes instead from OpenConcept’s ownWaveDragFromSections, whichCdadtAircraftModelinstalls when asked – so it is added to the parasite drag rather than to this polar. See What is checked against what.- model_name: ClassVar[str] = 'openavl_vortex_lattice'
- DRAG_DEPENDS_ON: ClassVar[tuple[str, ...]] = ('CL', 'CD0', 'area', 'AR', 'sweep', 'taper')
The lattice is rebuilt from all four wing numbers, so all four reach the drag – which is the substantive difference from a parabolic polar.
eis absent because this model computes the span efficiency instead of reading it.
- classmethod new_workspace()[source]
Return a library that solves with DifferentiableLattice, and only with it.
- Return type:
object
- classmethod build(*, planform, span_efficiency, zero_lift_drag, workspace=None)[source]
Build the lattice on this wing.
span_efficiencyis ignored, and that is the point.A case file flying the parabolic polar must state
ac|aero|polar|eas an assumption. Here the lattice computes it, so the stated value is deliberately not consulted – seespan_efficiencyfor what the wing actually achieves.workspaceis theLatticeLibrarythe caller owns. It is what makes this model cheap to construct despite being expensive to solve: the lattice results live in the library, not in the model, so rebuilding the model per Newton iteration costs nothing. PassedNone, the model keeps its own – correct, and slow under a solver.- Parameters:
planform (Planform)
span_efficiency (float)
zero_lift_drag (object)
workspace (object)
- Return type:
- polar_at(mach)[source]
Return the fitted, differentiated polar at one sampled Mach number.
Public because a study comparing what the lattice says against what a case file assumes needs the polar itself, not only the drag it produces.
- Parameters:
mach (float)
- Return type:
- property span_efficiency: float
The Oswald efficiency openavl reports for this wing, incompressible.
Reported rather than assumed. A case file flying the parabolic polar has to state a value for
ac|aero|polar|e; this is what the wing actually achieves, and comparing the two is the point of installing a lattice at all. Taken at M = 0 so that it is a property of the wing rather than of a flight condition;polar_at()carries the Mach dependence.
- coefficients(condition, planform)[source]
Return the coefficients at every point, from the polar fitted to this wing.
planformis accepted for the interface’s sake and checked against the one the lattice was built on, because a silent mismatch would report the drag of a different aeroplane.- Parameters:
condition (FlightCondition)
planform (Planform)
- Return type:
- drag_gradients(condition, planform)[source]
Return the exact derivatives of
CD, differentiated through the lattice itself.Every geometry derivative comes from
jax.jacrevover openavl’s own differentiable geometry update, chained through the closed-form polar:\[\frac{\partial C_D}{\partial g} = \frac{\partial C_{D_\mathrm{min}}}{\partial g} + \frac{\partial k}{\partial g}\,(C_L - C_{L_\mathrm{minD}})^2 - 2k\,(C_L - C_{L_\mathrm{minD}})\,\frac{\partial C_{L_\mathrm{minD}}}{\partial g}\]so
area,AR,sweepandtaperare all exact. An earlier version of this model derived the aspect-ratio term from \(k = 1/(\pi e A\!R)\) with the span efficiency held fixed – wrong by 1.8% – and reported nothing at all for sweep and taper, which an optimizer reads as “these do not matter”.eis zero, and that is correct – not missing. This model does not readac|aero|polar|e; it computes the span efficiency from the lattice. A case file’s stated value has no influence on this drag, so the derivative with respect to it genuinely is nothing. Comparedrag_gradients(), where it is the dominant term.- Parameters:
condition (FlightCondition)
planform (Planform)
- Return type:
dict[str, ndarray]
Return whether openavl can be imported.
openavl is an optional extra. Everything else in cdadt works without it, and the tests that need it skip rather than fail, so a machine without JAX still runs the whole suite honestly.
- Return type:
bool
openavl’s differentiable vortex lattice, driven from a cdadt planform.
This module exists so that cdadt.adapter.avl can state a drag polar and its exact
derivatives with respect to the wing from one source. It owns every openavl call cdadt makes;
nothing here is copied from openavl and nothing is patched.
Why the far field, and why the fit is exact
openavl reports induced drag twice: a near-field sum of pressures on the panels (CD) and a
Trefftz-plane far-field value (CDFF). They are not interchangeable. Read together with the
matching lift – CLFF with CDFF – the far-field pair satisfies
with openavl’s own reported span efficiency, to machine precision. That identity is what
test_the_fitted_curvature_is_openavls_own_span_efficiency checks, and it is the reason this
model is fitted on the far-field pair: any other pairing produces a polar the dependency itself
disagrees with. cdadt got this wrong twice – first by taking near-field CD, which claimed a
span efficiency of 1.056 for a planar wing, then by pairing far-field CDFF with the commanded
near-field lift, which biased the curvature by 2.2% and left the model’s own reported e of
0.990 inconsistent with the 0.969 its drag implied.
A vortex lattice is linear: the circulation is affine in angle of attack, the far-field lift is linear in it and the far-field drag is a quadratic form in the circulation. So
is not a curve fit but an identity, and three solves determine it exactly. The residual of a fourth
sample measures that claim rather than assuming it
(test_the_lattice_polar_is_exactly_quadratic).
What is held fixed
Twist and dihedral. The sections’ incidence angles are taken from the baseline lattice and are not design variables, because no case file declares them; the reference area, span and chord do move with the wing, which they must, or the coefficients would be normalised by a different aeroplane than the one being solved.
- cdadt.adapter.lattice.ANGLES_OF_ATTACK: tuple[float, ...] = (0.0, 0.08, 0.16)
Angles of attack, in radians, the polar is determined from. Three is the exact number of samples a quadratic needs; they are spread across the lift range a transport mission uses (roughly \(C_L\) 0 to 0.75 for the shipped wing) so that the conditioning of the solve is good, not because the answer depends on where they sit.
- class cdadt.adapter.lattice.DifferentiableLattice(planform, mach, chordwise, spanwise)[source]
Bases:
LatticeSolverA vortex lattice on a trapezoidal wing, differentiable with respect to that wing.
One instance describes one wing at one Mach number and one lattice density. It holds the snapshot openavl needs to rebuild geometry inside JAX – the panel topology and the baseline arrays – which is why it is an object rather than a function: that snapshot costs a lattice solve, and the primal fit and its Jacobian must be taken from the same one or they would describe subtly different lattices.
- Parameters:
planform (TrapezoidalPlanform) – The wing. Its four numbers become the JAX inputs the polar is differentiated against.
mach (float) – Freestream Mach number, reaching the lattice through openavl’s Prandtl-Glauert correction.
chordwise (int) – Panel counts on the half-wing.
spanwise (int) – Panel counts on the half-wing.
- Raises:
LoadsError – If openavl is not installed.
- static aircraft(planform, mach, chordwise, spanwise, aircraft_class)[source]
Build the openavl aircraft for a trapezoidal wing.
A static method on the class that solves the lattice rather than a free function beside it: constructing the lifting surface is an engineering step, and the brief puts those on classes. Static because it needs nothing from an instance – it is what an instance is built from, so it must run before
__init__has anything to offer.aircraft_classis injected rather than imported here so that this method carries no import of its own: the dependency is optional, and only the callers that need it import it. The reference quantities are the planform’s own, which is what makes the coefficients the lattice returns comparable with the ones the mission works in.- Parameters:
planform (TrapezoidalPlanform)
mach (float)
chordwise (int)
spanwise (int)
aircraft_class (type)
- Return type:
object
- fit()[source]
Solve the lattice, fit the polar and differentiate it with respect to the wing.
One forward pass and three reverse ones, taken from a single
jax.vjp()so that the coefficients and their derivatives come from the same solves. Computing the primal separately would repeat three lattice solves to arrive at numbers already in hand.- Returns:
LatticePolar – The three coefficients, openavl’s span efficiency, and the exact Jacobian. It refuses its own construction if the curvature is not positive.
- Return type:
- far_field_at(angle_of_attack)[source]
Return
(CLFF, CDFF, SPANEF)at one angle of attack, for verification.Public because the claim that the polar is exactly quadratic is only worth as much as the test that measures its residual against extra samples, and that test needs a way in.
- Parameters:
angle_of_attack (float)
- Return type:
tuple[float, float, float]
- class cdadt.adapter.lattice.LatticeLibrary(solver=<class 'cdadt.adapter.lattice.DifferentiableLattice'>)[source]
Bases:
objectThe polars solved so far, so that one wing is solved once per study rather than per node.
This exists because the alternative is module-level state. An
lru_cacheon a free function is the obvious way to memoise a geometry-keyed solve, and it is what this module used to do – but a decorator’s cache persists across everyProblemin the process, is mutable by anyone who can import the module, and hands two independently constructed models the same object. The project’s brief says to avoid globals entirely and minimise shared mutable state, and Architecture claims the suite enforces it; a cache reachable only through the object that owns it is what makes both true.Lifetime is the caller’s business, and that is the point. One library per analysis is correct: every phase of a mission flies the same wing, so they must share, and a library per component would re-solve the lattice fourteen times per design. The analysis group creates one and injects it; see
SizingMissionAnalysis.Not thread-safe, and does not need to be: OpenMDAO evaluates a model serially within a process, and a duplicated solve would cost time rather than correctness.
- Parameters:
solver (type[LatticeSolver])
- property solver: type[LatticeSolver]
The vortex-lattice code this library solves with.
- polar_for(planform, mach, chordwise, spanwise)[source]
Return the fitted, differentiated polar for one wing at one Mach number.
Keyed on the numbers that define the problem, because an optimizer asks for the same wing at every node of every phase within one design iteration and the lattice answer depends only on the shape and the Mach number. A design change misses and the lattice is re-solved, which is what keeps this exact rather than a surrogate that drifts.
- Parameters:
planform (TrapezoidalPlanform)
mach (float)
chordwise (int)
spanwise (int)
- Return type:
- class cdadt.adapter.lattice.LatticePolar(minimum_drag, curvature, lift_at_minimum_drag, span_efficiency, jacobian, describes='this wing')[source]
Bases:
objectThe drag polar of one wing at one Mach number, with its geometry Jacobian.
Read-only because it is cached and shared: every node of every mission phase within one design iteration reads the same object, and a mutable one would let a caller change what the next reader sees.
- Parameters:
minimum_drag (float) – \(C_{D_\mathrm{min}}\), the lowest lift-dependent drag the wing achieves. Zero for an untwisted, uncambered wing, and openavl says so exactly rather than approximately.
curvature (float) – \(k\), so that the drag grows as \(k\) times the squared lift excess.
lift_at_minimum_drag (float) – \(C_{L_\mathrm{minD}}\), where the polar bottoms out. Non-zero only with twist or camber.
span_efficiency (float) – openavl’s own
SPANEF, read rather than re-derived. Related to the curvature by \(k = 1/(\pi e A\!R)\).jacobian (tuple of tuple of float) – Derivatives of the three coefficients, in
POLAR_COEFFICIENTSorder, with respect to the four wing numbers, inGEOMETRY_VARIABLESorder.describes (str, optional) – What wing this is the polar of, for error messages.
- Raises:
LoadsError – If the curvature is not positive. Validated on construction rather than at the call site so that a polar which curves the wrong way cannot exist: induced drag grows with lift, and a negative curvature reaching an optimizer is worse than an error – it becomes an incentive to add lift for less drag, and the design walks off into a region the solver invented.
- COEFFICIENTS: ClassVar[tuple[str, ...]] = ('minimum_drag', 'curvature', 'lift_at_minimum_drag')
- GEOMETRY: ClassVar[tuple[str, ...]] = ('area', 'AR', 'sweep', 'taper')
- property minimum_drag: float
\(C_{D_\mathrm{min}}\), the lowest lift-dependent drag this wing achieves.
- property curvature: float
the drag grows as \(k\) times the squared lift excess.
- Type:
\(k\)
- property lift_at_minimum_drag: float
\(C_{L_\mathrm{minD}}\), the lift at which the polar bottoms out.
- property span_efficiency: float
openavl’s own
SPANEFfor this wing, read rather than re-derived.
- property jacobian: tuple[tuple[float, ...], ...]
Derivatives of the three coefficients with respect to the four wing numbers.
- property describes: str
What wing this is the polar of.
- property coefficients: tuple[float, float, float]
The three polar coefficients, in
POLAR_COEFFICIENTSorder.
- gradient(variable)[source]
Return how the three coefficients change with one wing number.
- Parameters:
variable (str) – One of
GEOMETRY_VARIABLES.- Returns:
tuple of float –
(d minimum_drag, d curvature, d lift_at_minimum_drag)per unit ofvariable.- Raises:
KeyError – If
variableis not one this polar was differentiated with respect to. Silently returning zero would be indistinguishable from a wing number that genuinely does not matter.- Return type:
tuple[float, float, float]
- class cdadt.adapter.lattice.LatticeSolver[source]
Bases:
ABCA vortex-lattice code, reduced to the one thing cdadt asks of it.
Two exist – openavl through
DifferentiableLattice, and OpenAeroStruct throughOpenAeroStructLattice– and they share nothing but this interface and the value object it returns. That is the point of it: a study picks its aerodynamics by naming a class, and the machinery around it does not know which code is underneath.A solver is constructed for one wing at one Mach number and one lattice density, because both codes snapshot geometry at construction.
fit()is what costs, andLatticeLibraryis what makes sure it is paid once per wing per study.
Aerodynamic loads from OpenAeroStruct: the second vortex lattice behind the same slot.
cdadt’s aerodynamics is a slot, not a solver. OpenAVLLoads fills it
with openavl; this module fills it with OpenAeroStruct, reached the only way cdadt is allowed to
reach it – through OpenConcept’s own VLM and TrapezoidalPlanformMesh, which live in
openconcept.aerodynamics.openaerostruct. cdadt never imports OpenAeroStruct itself, and a
contract test with no adapter exemption enforces that.
Why a second one is worth having
Not redundancy. Two independent codes behind one interface is what turns “the lattice says 0.99” into a claim that can be checked: on the shipped wing the two agree on induced drag to 0.67%, which is the strongest evidence cdadt has that its aerodynamics is driven correctly. A study can now make that comparison itself by changing one line of a case file, rather than reading it here.
The two are not interchangeable, and the differences are the interesting part:
Property |
openavl |
OpenAeroStruct |
|---|---|---|
Induced drag |
Trefftz-plane far field |
near-field panel sum |
Span efficiency |
reported ( |
inferred from the fit |
Compressibility |
Prandtl-Glauert |
none; |
Derivatives |
|
OpenMDAO analytic totals |
Polar in lift |
an identity, exact 1e-12 |
a fit, good to 2e-3 |
Two rows deserve more than a table cell.
Near field. Near-field induced drag is under-predicted on a swept wing badly enough that both
codes imply a span efficiency above 1 for this planar wing, which is impossible since elliptical
loading is optimal. openavl offers a far-field value and cdadt uses it; OpenAeroStruct’s VLM
publishes only fltcond|CDi, so this model reads 1.034 and is optimistic on induced drag by about
4% against the openavl model. It says so rather than correcting it with a fudge factor.
The polar is a fit here, not an identity. For openavl the quadratic is exact because the
Trefftz-plane drag is a quadratic form in a circulation that is affine in angle of attack – three
solves determine it and the residual is round-off. The near-field sum carries no such guarantee, and
it shows: measured at three angles the fit never sampled, the worst error is 2.2e-3 relative,
around \(C_L\) 0.16 where the drag is smallest. That is small enough to be usable and large
enough that it must be published rather than assumed, which is what
test_the_openaerostruct_polar_reports_its_own_fit_error does.
How the derivatives are exact
OpenMDAO already has them. TrapezoidalPlanformMesh and VLM both declare analytic partials,
so compute_totals gives \(\partial C_L/\partial g\) and \(\partial C_{D_i}/\partial g\)
for the four wing numbers directly. Those are differentiated through the polar fit here – the fit
is a linear solve, so its derivative is another linear solve – giving the geometry Jacobian of the
fitted polar, which is what every mission node is evaluated from.
- cdadt.adapter.oas.ANGLES_OF_ATTACK_DEG: tuple[float, ...] = (0.0, 4.5, 9.0)
Angles of attack, in degrees, the polar is determined from. Degrees rather than the radians openavl works in because that is the unit OpenConcept’s
VLMdeclaresfltcond|alphain, and converting here would be a units bug waiting to happen. Three is what a quadratic needs.
- class cdadt.adapter.oas.OpenAeroStructLattice(planform, mach, chordwise, spanwise)[source]
Bases:
LatticeSolverOpenConcept’s OpenAeroStruct vortex lattice, solved for one wing and fitted to a polar.
- Parameters:
planform (TrapezoidalPlanform) – The wing. Its four numbers drive
TrapezoidalPlanformMesh, which is parameterised by exactly the same four – which is why the two codes can be put on an identical wing.mach (float) – Accepted for the interface and not used: this lattice’s induced drag is Mach-free. It is recorded so the polar can say what it was asked for.
chordwise (int) – Mesh panels, streamwise and on the half span.
spanwise (int) – Mesh panels, streamwise and on the half span.
- Raises:
LoadsError – If OpenAeroStruct is not installed.
- MESH_INPUTS: ClassVar[tuple[str, ...]] = ('mesh.S', 'mesh.AR', 'mesh.sweep', 'mesh.taper')
The mesh inputs, in the order the Jacobian’s columns follow. These are OpenConcept’s names for the same four numbers
GEOMETRY_VARIABLESnames.
- coefficients_at(angle_of_attack_deg)[source]
Return
(CL, CDi)at one angle of attack, in degrees.- Parameters:
angle_of_attack_deg (float)
- Return type:
tuple[float, float]
- fit()[source]
Solve at three angles, fit the polar, and differentiate the fit with respect to the wing.
The fit is a determined linear system in \([a, b, c]\) for \(C_D = a C_L^2 + b C_L + c\), so differentiating it is another solve with the same matrix:
\[V \frac{\partial \mathbf{x}}{\partial g} = \frac{\partial \mathbf{C_D}}{\partial g} - \frac{\partial V}{\partial g}\mathbf{x}\]where \(\partial V/\partial g\) is non-zero only because the lift at a fixed angle of attack moves when the wing does. Dropping that term is the mistake that would make these derivatives silently approximate.
- Return type:
- class cdadt.adapter.oas.OpenAeroStructLoads(planform, zero_lift_drag=0.0, chordwise=6, spanwise=20, library=None)[source]
Bases:
AerodynamicLoadsLift-dependent drag from OpenConcept’s OpenAeroStruct vortex lattice.
The same shape as
OpenAVLLoadsand deliberately so: both fit a polar per geometry, both evaluate every mission node in closed form, both report exact geometry derivatives, and both are chosen by one line of a case file. What differs is the code underneath and what it can see – see this module’s own documentation, and What is checked against what for the comparison between them.- Parameters:
planform (Planform) – The wing to mesh. Must be a
TrapezoidalPlanform.zero_lift_drag (array_like, optional) – Parasite
CD0from outside the lattice. Default 0.chordwise (int, optional) – Mesh density.
spanwise (int, optional) – Mesh density.
library (LatticeLibrary, optional) – Where solved polars are kept. Injected by the analysis group so that all fourteen mission phases share one; a private one is made when none is offered.
- Raises:
LoadsError – If OpenAeroStruct is absent, or the planform is not one a mesh can be built from.
- model_name: ClassVar[str] = 'openaerostruct_vortex_lattice'
- DRAG_DEPENDS_ON: ClassVar[tuple[str, ...]] = ('CL', 'CD0', 'area', 'AR', 'sweep', 'taper')
The mesh is rebuilt from all four wing numbers.
eis absent for the same reason as in the openavl model: this one derives the span efficiency rather than reading a case file’s.
- classmethod new_workspace()[source]
Return a library that solves with OpenAeroStructLattice, and only with it.
- Return type:
object
- classmethod build(*, planform, span_efficiency, zero_lift_drag, workspace=None)[source]
Build the mesh on this wing.
span_efficiencyis ignored: the lattice implies one.workspacemust be aLatticeLibrarysolving with this model’s own solver. A library is a store of solved polars, and a polar solved by openavl is not interchangeable with one solved by OpenAeroStruct – they differ by 5% on this wing, because one is a far-field value and the other near-field. Handing this model the other code’s library would silently report the other code’s drag, so it is refused by name.- Parameters:
planform (Planform)
span_efficiency (float)
zero_lift_drag (object)
workspace (object)
- Return type:
- polar()[source]
Return the fitted, differentiated polar for this wing.
No Mach argument, and no interpolation across Mach either – unlike the openavl model, which needs both. This lattice’s induced drag does not depend on Mach at all, so a Mach sweep would be fifteen solves producing five identical answers.
- Return type:
- property span_efficiency: float
The Oswald efficiency this lattice’s polar implies.
- coefficients(condition, planform)[source]
Return the coefficients at every point, from the polar fitted to this wing.
- Parameters:
condition (FlightCondition)
planform (Planform)
- Return type:
- drag_gradients(condition, planform)[source]
Return the exact derivatives of
CD, from OpenMDAO’s totals through the polar fit.The same chain rule the openavl model applies, over a Jacobian obtained a different way:
\[\frac{\partial C_D}{\partial g} = \frac{\partial C_{D_\mathrm{min}}}{\partial g} + \frac{\partial k}{\partial g}\,(C_L - C_{L_\mathrm{minD}})^2 - 2k\,(C_L - C_{L_\mathrm{minD}})\,\frac{\partial C_{L_\mathrm{minD}}}{\partial g}\]- Parameters:
condition (FlightCondition)
planform (Planform)
- Return type:
dict[str, ndarray]
- cdadt.adapter.oas.openaerostruct_is_available()[source]
Return whether OpenConcept’s OpenAeroStruct-backed components can be imported.
Reached through OpenConcept rather than by importing OpenAeroStruct, which cdadt may not do. The subpackage raises on import when OpenAeroStruct is absent, so this is the honest probe.
- Return type:
bool