Architecture

Every piece of cdadt is a class with encapsulated state. There is no global state, no module-level cache, and no free function that does discipline work. This page says what the classes are, what each owns, and why the division is drawn where it is.

The layers

CommandLineInterface      composes Command subclasses; owns the parser and the dispatch
  |                         SizeCommand / OptimizeCommand are StudyCommands
  |                         InspectCommand reads the interface without running
  |
Config                    one YAML case file, validated hard
  |                         read through CaseFileSection, which carries its own address
  |
  +-- Aircraft            composes the airframe disciplines, routes every ac| name
  |     +-- Geometry      wing, fuselage, nacelles, gear
  |     +-- Aerodynamics  span efficiency, airfoil, flaps, envelope
  |     +-- Propulsion    engine rating and count
  |     +-- Stability     empennage shape, tail areas
  |     +-- Structures    load-carrying airframe mass breakdown
  |     +-- Weights       payload, cabin, the closed weight rollup
  |
  +-- Performance         owns the InitialConditions and the ContinuationLadder
  |
  +-- OpenConceptSizingBox   the black box: loaded by name, set, converged, read
        |                      RunDirectory decides where its problem writes
        |
        +-- cdadt.models      physics cdadt owns. OpenMDAO and numpy only; imports no dependency
        |     AerodynamicLoads    the pluggable slot -- coefficients from a flight condition
        |     PolarLoads          a parabolic polar; reproduces the reference exactly
        |     TrapezoidalPlanform the case file's four wing numbers -> lifting surface
        |
        +-- cdadt.adapter     the only package that may import a dependency
              AerodynamicLoadsComp  evaluates a loads model over a phase, publishes `drag`
              DifferentiableLattice openavl, and the exact geometry Jacobian
              OpenAVLLoads          that lattice as an AerodynamicLoads
              WingSectionsComp      the planform as sections, for OpenConcept's wave drag
              CdadtAircraftModel    cdadt's drag + OpenConcept's engine and weights
              SizingMissionAnalysis installs it into FullMissionWithReserve
SizingAnalysis            builds, converges, reads -> SizingResults
  |                         the coordinator; every collaborator is injectable
Optimizer                 declares design variables, objective and CertificationBasis,
  |                       converges a baseline, drives -> OptimizationOutcome
  |
StudyArtifacts            writes the record into the run directory
      MissionTrajectory   the seven steady phases, discovered and ordered
      TakeoffTrajectory   the balanced field: the continued and rejected paths

The command line is at the top rather than off to one side because it is the only caller that supplies a RunDirectory, and therefore the only one that causes anything to be written to disk in a place of cdadt’s choosing. See Output files.

The arrangement is open/closed in four places, each tested rather than asserted: a new discipline is a new Discipline passed to Aircraft, a new command is a new Command passed to CommandLineInterface, a new black box is a different model: line in the case file, and a new aerodynamics is a new AerodynamicLoads named in that case file’s options. None of the four requires editing anything that already exists.

One run, end to end

What actually happens when cdadt size cases/b738.yaml is typed. Read it downwards; the only loop is the one marked, and it is the weight closure.

cases/b738.yaml
     |
     |  Config.from_dict -- every section validated, unknown keys refused
     v
+-------------------------------------------------------------------------+
|  SizingAnalysis          the coordinator; collaborators injected         |
|                                                                         |
|   Aircraft ---> 7 disciplines, each owning one slice of the ac| names    |
|   Performance -> initial conditions + the continuation ladder           |
|   CertificationBasis -> the regulations this study is judged against    |
+-------------------------------------------------------------------------+
     |  writes ac| values, seeds, solver settings
     v
+-------------------------------------------------------------------------+
|  OpenConceptSizingBox     loaded by name from the case file              |
|                                                                         |
|    +------------------------------------------------------------+       |
|    |  the analysis group  (OpenConcept's, or cdadt's own)        |       |
|    |                                                            |       |
|    |   geometry -> tails, wetted areas, MAC                      |       |
|    |   maximum lift -> CLmax clean and flapped                   |       |
|    |   empty weight -> OEW                                       |       |
|    |                                                            |       |
|    |   FullMissionWithReserve                                    |       |
|    |     v0v1 v1v0 v1vr rotate | climb cruise descent | reserve  |       |
|    |       balanced field      |    design mission     | loiter  |       |
|    |                                                            |       |
|    |     each phase instantiates ONE aircraft model per phase:   |       |
|    |       B738AircraftModel        (OpenConcept's)              |       |
|    |       CdadtAircraftModel       (cdadt's -- see below)       |       |
|    +------------------------------------------------------------+       |
|                      ^                              |                   |
|                      '---- Newton: MTOW <-> fuel ---'  <== the one loop  |
+-------------------------------------------------------------------------+
     |  read back by name, in declared units
     v
SizingResults -> ResponseCatalog -> report.txt, results.json, 3 figures, n2.html

Inside one phase, when the aerodynamics is cdadt’s:

phase gives:  fltcond|CL  fltcond|q  fltcond|M  fltcond|h  throttle  ac|...
                   |          |          |          |         |
   +---------------+----------+----------+----------+         |
   |                                                          |
   v                                                          v
+--------------------------------+                  +------------------------+
|  cdadt: the drag               |                  |  OpenConcept: the rest |
|                                |                  |                        |
|  ParasiteDragCoefficient  CD0  |                  |  RubberizedTurbofan    |
|  [+ WaveDragFromSections] --.  |                  |    -> thrust, fuel flow|
|                             v  |                  |  Integrator -> fuel    |
|  AerodynamicLoadsComp          |                  |  AddSubtract -> weight |
|    builds a planform           |                  +------------------------+
|    builds the loads model      |                            |
|    model.coefficients() -> CD  |                            |
|    drag = CD * q * S           |                            |
+--------------------------------+                            |
                  |                                           |
                  v                                           v
                drag                                 thrust, weight
                  '----------> back to the phase <-----------'

The loads model itself is chosen by the case file and knows nothing about any of this:

AerodynamicLoads                     (cdadt.models -- imports no dependency)
  |
  +-- PolarLoads                     CD = CD0 + CL^2 / (pi e AR)
  |
  +-- OpenAVLLoads                   (cdadt.adapter) -.
  |                                                    >-- LatticeSolver
  +-- OpenAeroStructLoads            (cdadt.adapter) -'      |
                                                             +-- DifferentiableLattice --> openavl
                                                             +-- OpenAeroStructLattice --> OpenConcept -> OpenAeroStruct
                                                                   |
                                                         LatticeLibrary: solved polars,
                                                         one per study, injected, never global

Why the physics is split in two

cdadt.models and cdadt.adapter look like one layer and are deliberately two.

A discipline is an interface to the box: it owns a slice of the variable namespace, computes nothing, and that invariant is what the ownership tests enforce. A model is physics installed into the box: it computes, and it is chosen per study. Neither could absorb the other without losing the property that makes it useful.

The split inside the physics is by dependency, not by subject. cdadt.models imports neither OpenConcept nor openavl, so a loads model can be written, differentiated and tested on a machine with neither installed – and a contract test enforces it. cdadt.adapter is the one package allowed to import a dependency, because installing cdadt’s aerodynamics into OpenConcept’s mission means composing OpenConcept’s propulsion and weight blocks around it, and an aircraft model must be an OpenMDAO group OpenConcept instantiates. That exemption is one package wide and is the whole of it. See Supplying your own aerodynamics for the layer itself and What is checked against what for what each part is validated against.

What a discipline is

A discipline is one engineering domain, modelled as a class, and it owns exactly two things:

The parameters it puts into the box. Which ac| variables belong to its domain, their values, their units and their provenance. Held as Parameter objects, reachable only through methods that validate what they are given – a non-finite value is rejected at the point of assignment rather than surfacing later as an unexplained convergence failure.

The responses it reads back out. Which quantities the box produces belong to its domain, where they live inside it, and in what units to read them. Held as Response objects, which is what allows the whole interface to be listed without running anything.

What a discipline is not

It does not compute physics. There is no compute, no residual and no partial derivative in any of them, because the aerodynamics, the weight correlations, the engine deck, the tail sizing and the entire mission are computed inside the black box by OpenConcept. A cdadt discipline is the encapsulated, validated interface to its slice of that box.

This is stated bluntly because the alternative is worse. A cdadt.disciplines.aerodynamics module that looked like it modelled aerodynamics, in a tool whose results are quoted in a thesis, would be a page of documentation away from a false claim. It sets the inputs to somebody else’s drag buildup and reads two lift coefficients back.

Discipline is an abstract base, and discipline_name is genuinely abstract – it is the one thing no domain can inherit, being the key it is reported and looked up under. A subclass that omits it is refused when the class is written, by __init_subclass__, rather than surfacing later as a duplicate-name error from Aircraft or not at all. Nothing else is abstract, because a subclass overrides no methods: it declares three class attributes and that is the whole contract.

Certification is a domain, and is not a discipline

CertificationBasis encapsulates the certification domain, and SizingAnalysis owns it alongside the disciplines. It is deliberately not a Discipline, and the reason is the definition above: a discipline owns a slice of the black box’s variable interface – variables it sets, responses it reports. Certification sets nothing and publishes nothing. It reads quantities the other disciplines already report and judges them against stated limits, which is a different relationship to the box, and forcing it into the same base class would describe it wrongly: it would appear in the ownership map owning nothing and in the results table reporting nothing.

It is owned by the analysis rather than by Optimizer, which used to build it. That mattered in practice: asking whether a sized aeroplane meets its certification basis required constructing an optimizer that was never going to run. A sizing study can now answer it directly, and a test does exactly that.

Ownership, and why it is checked

Each discipline declares the variables it owns as shell-style patterns:

Geometry

ac|geom|wing|*, ac|geom|fuselage|*, ac|geom|nacelle|*, ac|geom|maingear|*, ac|geom|nosegear|*

Aerodynamics

ac|aero|*

Propulsion

ac|propulsion|*

Stability

ac|geom|hstab|*, ac|geom|vstab|*

Structures

nothing

Weights

ac|weights|*, ac|num_*, ac|cabin_pressure

Performance

mission.*

Aircraft routes each declared parameter to the one discipline that claims it, and refuses a parameter that no discipline owns or that two would claim. A contract test then walks the whole settable set of a built box – 241 variables for the shipped case, read off the live model rather than from a list – and asserts that every one of them has exactly one owner. A variable cdadt could set but no discipline claims is a hole in the model of the interface, whether or not any case file happens to set it.

Two of those rows deserve explanation.

Structures owns nothing, and that is the honest answer. Primary structure is sized inside the box by correlations that read geometry, maximum takeoff weight and maximum landing weight – all owned elsewhere or computed by the box. There is no structural parameter left for cdadt to set: no material, no load factor, no spar layout. Inventing one so that the class had state would be a parameter nothing reads. What it does own is the reporting of structure: the seven component masses, so that a weight figure can be argued with rather than only quoted.

The empennage is Stability, not Geometry. Its areas are outputs, not inputs – the box sizes both surfaces by tail volume coefficient – so the shape parameters and the resulting areas belong together, in the domain the method belongs to.

Performance is the odd one

Performance is the one discipline whose encapsulated state is not a bag of scalars. It owns the pair OpenConcept’s own run scripts write by hand: the InitialConditions – everything set_values(prob, num_nodes) writes, which is the design range, the cruise altitude, the reserve mission, the per-phase schedules and the solver’s starting guesses – and the ContinuationLadder that makes the hard ones reachable. Those are design inputs in exactly the sense the geometry parameters are: change the range and you change the aeroplane. See The mission.

It is also the discipline the certification constraints are written against: field length, decision and safety speeds, engine-out climb gradient, fuel, and the throttle history of every phase.

Adding a discipline

Subclass Discipline, declare what it owns and what it reports, and pass it to Aircraft:

from cdadt import Aircraft, Discipline, Response
from cdadt.disciplines import AIRCRAFT_DISCIPLINES


class Cost(Discipline):
    """Acquisition and operating cost, if the box publishes any."""

    discipline_name = "cost"
    description = "Acquisition and operating cost"
    owned_patterns = ("ac|cost|*",)
    reported = (Response("acquisition_cost", "costs.acquisition", "USD", optional=True),)


aircraft = Aircraft(parameters, disciplines=(*AIRCRAFT_DISCIPLINES, Cost))

Nothing else changes. The ownership check will immediately tell you if the new patterns overlap an existing discipline’s, and collect() will tell you if the box does not publish what the new discipline claims to report.

The rules, and the tests that enforce them

Rule

Test that enforces it

No cdadt module imports OpenConcept

test_no_cdadt_module_imports_openconcept

No cdadt class inherits from OpenConcept

test_no_cdadt_class_inherits_from_openconcept

The OpenConcept working tree is untouched

test_the_openconcept_working_tree_is_clean

No local OpenConcept commit affects a module cdadt loads

test_no_locally_committed_openconcept_change_touches_a_module_cdadt_loads

Every settable variable of the box has exactly one owner

test_every_settable_variable_of_the_box_has_exactly_one_owner

Every required response exists in the box

test_every_reported_response_exists_in_the_box

The base Discipline cannot be instantiated

test_the_base_class_cannot_be_instantiated

Design decisions worth knowing

Values are validated where they are set, not where they are used. A Parameter refuses a non-numeric or non-finite value in its setter. A NaN that reaches OpenMDAO propagates silently through a Newton solve and emerges as a convergence failure with no attribution.

Case files reject unknown keys. A silently ignored key is the failure mode a configuration file is most prone to: the run succeeds and answers a different question than the one that was asked.

Optimizations are checked against a cheap probe of the box before they start. A small build costs a fraction of a second and knows every name the box publishes, so a misspelled design variable is a message with suggestions rather than an OpenMDAO error thrown out of setup.

Results are read whole. Every discipline’s responses are collected on every run, not whichever few a caller asked for. That is what makes a run report complete and two runs comparable without re-running either.

There is no shared mutable state

Not as an aspiration – as a property the suite enforces. Two contract tests parse and walk the whole package:

test_cdadt_has_no_module_level_mutable_state

No module binds a mutable object at import time. __all__ is exempt: it is a list by language convention, is never mutated, and describes the module rather than holding state.

test_cdadt_has_no_class_level_mutable_state

No class carries a mutable class attribute. Immutable class attributes – the ownership patterns, the response tuples, the allowed-key tuples on every config section – are the intended way to declare what a class is, and are unaffected.

Everything a discipline, an aircraft, a mission or an analysis holds is instance state, reached through properties that validate what they are given.

This cost two design changes, which is the point of testing it. An earlier version gave a Requirement base class a class-level registry dictionary, populated by __init_subclass__, so that declaring a subclass made its kind nameable in a case file. Convenient, and shared mutable state: two studies in one process shared one registry, defining a class anywhere mutated it, and what a case file resolved to depended on what happened to have been imported.

The fix went further than moving the mapping onto an instance. There is no requirement class hierarchy at all now. A constraint is a bound on a named quantity plus its provenance – Constraint – and the set a design is held to is a CertificationBasis built from the case file’s own constraints list. Nothing has to be registered, subclassed or imported to make a new one nameable, because there is nothing to name: the quantity is a response the disciplines already report, and the regulation is a string in the YAML.

from cdadt import CertificationBasis, Constraint

basis = CertificationBasis.from_specs(config.constraints, catalog)

Two bases are independent, and a duplicate constraint name is refused by name rather than resolved by letting the later one win – which is what OpenMDAO would otherwise do silently.

The only module-level functions in the package are argument parsing in cdadt.cli and case-file validation helpers in cdadt.config. Neither performs a discipline calculation – there are none to perform in cdadt, because the physics is inside the black box.