Output files

A run that prints its numbers and exits leaves nothing to attach to a design review. Every study writes a complete record – there is no flag to remember, because a record that depends on remembering a flag is a record you find out you do not have afterwards:

cdadt size cases/b738.yaml
cdadt optimize cases/b738_optimization.yaml

Each invocation gets its own directory under run_outputs/, named for the case and the moment it ran, so repeated runs of one case accumulate instead of overwriting each other:

run_outputs/
  b738_20260728_201512_out/
    report.txt        results.json      n2.html              <- cdadt
    mission.pdf       trajectory.pdf    takeoff.pdf          <- cdadt
    .openmdao_out     reports/                               <- OpenMDAO
  b738_optimization_20260728_201551_out/
    ... the same, plus
    IPOPT.out                                                <- the driver

File

What it is

n2.html

OpenMDAO’s N2 diagram of the built model: every component, every connection, every solver. Generated from the live problem, so it is the model that was actually run rather than a drawing of one.

mission.pdf

The mission profile, reproducing B738_sizing.py’s own plot_results.

trajectory.pdf

The trajectory, reproducing B738.py’s show_outputs.

takeoff.pdf

The balanced field: the continued takeoff and the rejected one.

report.txt

The same text the command printed.

results.json

The same numbers as structured data. For an optimization this is the full record: baseline, optimum, and every constraint with its provenance, margin and status.

.openmdao_out, reports/

OpenMDAO’s own, not cdadt’s: its N2, its inputs report, and for an optimization its driver-scaling and optimizer reports.

IPOPT.out

The optimizer’s own log, written by pyOptSparse.

Where the directory comes from

cdadt does not invent this layout. OpenMDAO already has it: a openmdao.api.Problem given a name and a work_dir writes into <work_dir>/<name>_out, and everything downstream follows it – its reports, and the optimizer’s log, since pyOptSparse writes IPOPT.out into the directory the problem set up in.

So RunDirectory names the problem and says where the work directory is, and then cdadt writes its own six files into the directory OpenMDAO made. Both halves of the record land in one place because they are the same place, not because anything is collected afterwards. The _out suffix is OpenMDAO’s convention, kept rather than fought.

--run-outputs <dir> moves the root; --json <file> additionally writes the numbers somewhere of your choosing. run_outputs/ and *_out/ are both in .gitignore, since every file in them is regenerated by one command.

Two mission figures, because there are two reference figures

OpenConcept plots the mission twice, and differently. B738.py’s show_outputs draws seven panels; B738_sizing.py’s plot_results draws a 2x3 of different quantities, one of which carries two series. cdadt drives B738_sizing.py, so that is the figure it must reproduce – but reproducing only it would drop the fuel, airspeed and lift-coefficient panels the other example considers worth showing. Both are written rather than choosing which example to be faithful to.

mission.pdfB738_sizing.py, plot_results:

Panel

Variables

Altitude (ft)

fltcond|h

Mach number

fltcond|M

Vertical speed (ft/min)

fltcond|vs

Weight (lb)

weight

Longitudinal force (lb)

drag and thrust, two series on one axis

Throttle (%)

throttle, as a percentage

trajectory.pdfB738.py, show_outputs:

Variable

Units

Panel

fltcond|h

ft

Altitude

fltcond|Ueas

kn

Equivalent airspeed

fuel_burn_integ.fuel_burn

lbm

Fuel burned

throttle

Throttle setting

fltcond|vs

ft/min

Vertical speed

fltcond|M

Mach number

fltcond|CL

Lift coefficient

All against range in nautical miles.

One difference from B738.py, and it is only in where the number is read from: it plots fuel_used, which is a promoted input of each phase and therefore not unambiguously readable – a promoted input name can address several components at once, which is why readable() omits them. What the box publishes as an output is each phase’s fuel integrator, and each phase’s carries on from the end of the one before rather than restarting at zero. So the panel is cumulative mission fuel, the same curve B738.py draws.

That is an identity, not an observation: the response catalogue defines total_fuel as mission.loiter.fuel_burn_integ.fuel_burn_final – the last node of the last phase of this very trace – and block_fuel as the same thing at the end of descent. Both are therefore readable straight off the panel. On the shipped case the trace runs from 179.31 lbm at the first climb node, the takeoff roll already in it, to 41,000.07 lbm at the end of the loiter, which is total_fuel = 18,597.3177 kg.

The phases are discovered, and ordered by where they happen

Two questions, and neither is answered with a list of names.

Which subsystems are phases. A steady flight phase integrates its own state and so publishes ode_integ_phase.range_final. The ground-roll phases – v0v1, v1vr, rotate and the rejected takeoff v1v0 – publish range but not that, so they are not part of the flown trajectory. Keeping v1v0 out matters: it retraces ground the aircraft has already covered, and drawn on a range axis it is a trajectory that doubles back. It gets its own figure instead, where doubling back is the point.

The discriminator is published as an output, which means readable() and not has(). The two disagree here, and only on the phases this has to exclude: has falls back to get_val, and v0v1, v1vr and v1v0 all answer it for the marker while publishing no such output. A test holds that distinction, because the failure it prevents is a plot that still looks like a plot.

What order they are flown in. Not alphabetical – that puts loiter before the reserve climb. Not the order the box lists them in either, since it publishes its outputs sorted by name. Phases are ordered by the abscissa itself: where each one starts. For the shipped case that recovers climb, cruise, descent, the three reserve phases, then loiter, and it is a fact read off the converged mission rather than a convention written down here.

Nothing about those seven is written down in cdadt, which is the same reason the black box itself is named in the case file rather than imported. A mission model with a second diversion, or without a loiter, plots correctly with nothing changed here.

A quantity that only some phases publish is dropped from the figure rather than drawn with a gap, because a trajectory drawn across a hole reads as a trajectory rather than as missing data. Asking for one by name instead raises, naming the phase that does not publish it.

Note

Read the trajectory off a converged box. Reading one mid-solve produces a plot of a state the solver was passing through, and nothing about the picture says so. The command line only writes artefacts after the run has converged; the classes will do as they are told.

The takeoff, which neither example plots

takeoff.pdf has no counterpart in B738.py or B738_sizing.py. It is here because the takeoff is where the field length comes from, and “balanced” is a claim about two paths that a number in a table states but cannot show.

From brake release the aeroplane accelerates to V1 (v0v1). There it either continues – accelerating to rotation and lifting off, v1vr then rotate – or rejects and brakes to a stop (v1v0). Both are drawn against ground distance, as true airspeed, altitude, throttle and weight.

On the shipped case the two paths lie on top of each other to V1 = 135.1 kn at about 3,150 ft, then separate: the continued takeoff accelerates to V2 and climbs through the 35 ft screen height, while the rejected one chops the throttle to zero and decelerates. Both end at about 5,250 ft – which is the reported balanced field length of 5,247.79 ft. That coincidence is the definition of balanced, and it is visible rather than asserted. A test holds it, comparing both path ends against mission.bfl.distance_continue.

This is also why the ground roll cannot simply be prepended to the mission figure: on a range axis the rejected takeoff runs back over ground the aeroplane has already covered.

Unlike the mission phases, these four are named rather than discovered. Which phase is the continued takeoff and which is the rejected one is semantics, and no output the box publishes carries it. The names are OpenConcept’s own, and a box without them raises rather than guessing.

From Python

Pass a RunDirectory to get the same layout the command line produces, including OpenMDAO’s own files:

from cdadt import Config, RunDirectory, SizingAnalysis, StudyArtifacts

run = RunDirectory.for_case("cases/b738.yaml", stamp="20260728_193000")
analysis = SizingAnalysis(Config.from_yaml("cases/b738.yaml"), run=run)
results = analysis.run()

artifacts = StudyArtifacts(run.path)
artifacts.write_text(results.report(analysis.catalog), "report.txt")
artifacts.write_json(results.to_dict(), "results.json")
artifacts.write_n2(analysis.box)
artifacts.write_mission_profile(analysis.box, title="B738 design mission")
artifacts.write_trajectory(analysis.box, title="B738 design mission")
artifacts.write_takeoff(analysis.box, title="B738 design mission")

The stamp is passed in rather than read from a clock, so a study can be reproduced into a named directory and a test can assert on the path. Omitting run entirely – the default – builds the problem with reports off, so nothing is written when it is built. A driver is the exception: pyOptSparse writes IPOPT.out into the problem’s output directory and OpenMDAO creates that directory on demand, which is exactly why the command line always supplies a run directory rather than leaving an optimization’s log wherever it was run from.

Matplotlib is an optional dependency, since only the figures need it:

pip install -e ".[plot]"

Without it, everything else still works and the plot raises an ArtifactError naming the extra to install.