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 |
|---|---|
|
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. |
|
The mission profile, reproducing |
|
The trajectory, reproducing |
|
The balanced field: the continued takeoff and the rejected one. |
|
The same text the command printed. |
|
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’s own, not cdadt’s: its N2, its inputs report, and for an optimization its driver-scaling and optimizer reports. |
|
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.pdf – B738_sizing.py, plot_results:
Panel |
Variables |
|---|---|
Altitude (ft) |
|
Mach number |
|
Vertical speed (ft/min) |
|
Weight (lb) |
|
Longitudinal force (lb) |
|
Throttle (%) |
|
trajectory.pdf – B738.py, show_outputs:
Variable |
Units |
Panel |
|---|---|---|
|
ft |
Altitude |
|
kn |
Equivalent airspeed |
|
lbm |
Fuel burned |
|
– |
Throttle setting |
|
ft/min |
Vertical speed |
|
– |
Mach number |
|
– |
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.