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.loads

The OpenMDAO component that evaluates a AerodynamicLoads over a phase and publishes drag.

cdadt.adapter.aircraft

The per-phase aircraft model OpenConcept’s mission instantiates: cdadt’s loads, OpenConcept’s engine deck, fuel integrator and weight bookkeeping.

cdadt.adapter.analysis

The sizing analysis group that installs the aircraft model into FullMissionWithReserve – the thing a case file names as its black box.

cdadt.adapter.lattice

Every openavl call cdadt makes: the differentiable vortex lattice, the polar fitted from it, and the exact geometry Jacobian.

cdadt.adapter.avl

cdadt.adapter.lattice presented as an AerodynamicLoads, 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: ExplicitComponent

Evaluate 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 – through DRAG_DEPENDS_ON – which partials this component should declare.

workspaceobject, optional

Passed to the model’s build unread and unexamined. Somewhere for a model to keep results too expensive to recompute per evaluation; see build().

Notes

Only drag is 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 S belongs here, because q and S are 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: ExplicitComponent

Publish 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|taper and ac|geom|wing|toverc; outputs are y_sec (the tip station, negative, one value because the root’s is zero by convention), chord_sec (tip then root) and toverc_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 an ExecComp in 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

compute(inputs, outputs)[source]

Publish the tip station and the two chords, outboard first.

Parameters:
  • inputs (Any)

  • outputs (Any)

Return type:

None

compute_partials(inputs, partials)[source]

Publish the closed-form derivatives of the stations and chords.

Parameters:
  • inputs (Any)

  • partials (Any)

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: Group

One 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 AerodynamicLoads this 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 supplying CD0. None means 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’s B738AircraftModel has 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.

initialize()[source]

Declare what makes this model specific to a phase and a study.

Return type:

None

setup()[source]

Compose the aerodynamics, the propulsion and the weight bookkeeping.

Return type:

None

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: Group

A 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 the AerodynamicLoads to 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.

initialize()[source]

Declare the grid and the aerodynamics.

Return type:

None

setup()[source]

Compose the aeroplane, the weights and the mission.

Return type:

None

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:

\[C_D = C_{D_\mathrm{min}} + k\,(C_L - C_{L_\mathrm{minD}})^2\]

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: AerodynamicLoads

Lift-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 CD0 from 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 own WaveDragFromSections, which CdadtAircraftModel installs 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. e is 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_efficiency is ignored, and that is the point.

A case file flying the parabolic polar must state ac|aero|polar|e as an assumption. Here the lattice computes it, so the stated value is deliberately not consulted – see span_efficiency for what the wing actually achieves.

workspace is the LatticeLibrary the 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. Passed None, 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:

OpenAVLLoads

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:

LatticePolar

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.

planform is 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:
Return type:

AeroCoefficients

drag_gradients(condition, planform)[source]

Return the exact derivatives of CD, differentiated through the lattice itself.

Every geometry derivative comes from jax.jacrev over 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, sweep and taper are 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”.

e is zero, and that is correct – not missing. This model does not read ac|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. Compare drag_gradients(), where it is the dominant term.

Parameters:
Return type:

dict[str, ndarray]

cdadt.adapter.avl.openavl_is_available()[source]

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

\[C_{D_\mathrm{FF}} = \frac{C_{L_\mathrm{FF}}^2}{\pi\,e\,A\!R}\]

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

\[C_D = C_{D_\mathrm{min}} + k\,(C_L - C_{L_\mathrm{minD}})^2\]

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).

Why the derivatives come from openavl and not from a formula

Writing \(k = 1/(\pi e A\!R)\) and holding \(e\) fixed gives an aspect-ratio derivative that is wrong by 1.8% for the shipped wing, and gives nothing at all for sweep and taper – which an optimizer reads as “these do not matter”. Both are avoided here by differentiating the lattice itself: openavl.jax.geom_jax.update_geometry() rebuilds the panels inside JAX from the four numbers a case file declares, so jax.jacrev returns the derivatives of the fitted polar with respect to area, aspect ratio, sweep and taper directly.

Reverse mode, not forward: openavl’s circulation solve is registered as a custom_vjp, so jax.jacfwd raises “can’t apply forward-mode autodiff (jvp) to a custom_vjp function”. Three reverse passes cost the same as four forward ones here anyway.

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: LatticeSolver

A 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_class is 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:

LatticePolar

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: object

The 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_cache on 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 every Problem in 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:
Return type:

LatticePolar

clear()[source]

Forget every solved polar. For a study that wants to measure the solve cost again.

Return type:

None

class cdadt.adapter.lattice.LatticePolar(minimum_drag, curvature, lift_at_minimum_drag, span_efficiency, jacobian, describes='this wing')[source]

Bases: object

The 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_COEFFICIENTS order, with respect to the four wing numbers, in GEOMETRY_VARIABLES order.

  • 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 SPANEF for 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_COEFFICIENTS order.

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 of variable.

Raises:

KeyError – If variable is 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]

drag_at(lift)[source]

Evaluate the polar at one or many lift coefficients.

Parameters:

lift (ndarray)

Return type:

ndarray

class cdadt.adapter.lattice.LatticeSolver[source]

Bases: ABC

A vortex-lattice code, reduced to the one thing cdadt asks of it.

Two exist – openavl through DifferentiableLattice, and OpenAeroStruct through OpenAeroStructLattice – 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, and LatticeLibrary is what makes sure it is paid once per wing per study.

abstractmethod fit()[source]

Solve the lattice and return its polar, with the exact geometry Jacobian.

Returns:

LatticePolar – Which refuses its own construction if the curvature is not positive, so an implementation does not have to check that itself.

Return type:

LatticePolar

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 (SPANEF)

inferred from the fit

Compressibility

Prandtl-Glauert

none; CDi is Mach-free

Derivatives

jax.jacrev

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 VLM declares fltcond|alpha in, 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: LatticeSolver

OpenConcept’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_VARIABLES names.

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:

LatticePolar

class cdadt.adapter.oas.OpenAeroStructLoads(planform, zero_lift_drag=0.0, chordwise=6, spanwise=20, library=None)[source]

Bases: AerodynamicLoads

Lift-dependent drag from OpenConcept’s OpenAeroStruct vortex lattice.

The same shape as OpenAVLLoads and 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 CD0 from 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. e is 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_efficiency is ignored: the lattice implies one.

workspace must be a LatticeLibrary solving 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:

OpenAeroStructLoads

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:

LatticePolar

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:
Return type:

AeroCoefficients

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:
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