MDAO

Requires the mdao extra (pip install "longeron[mdao]").

Project SysML v2 part trees and calcs onto OpenMDAO Problems (spike).

Mapping:

  • calc def -> ExplicitComponent whose compute() calls the interpreter (calc_component()).

  • part tree -> nested Group per part usage; each derived attribute (value expression referencing other features) becomes an ExplicitComponent; free attributes (literal values) become IndepVarComp outputs, so they can be design variables.

  • DISCIPLINE GROUPING: when a derived attribute’s value invokes a calc def that lives in its own package (e.g. DeepScout::Propulsion), the attribute’s component is housed in an OpenMDAO Group named after that package (outputs promoted, so flat names keep working). The SysML package structure is the source of the grouping – organize the calc defs into discipline packages and the generated N2 shows the classic Aerodynamics / Propulsion / Structures / Performance blocks. Calcs owned by a namespace enclosing the part definition itself are shared context, not disciplines, and stay ungrouped (ProblemBuild.disciplines records the mapping).

  • attribute cross-references -> connect() between promoted names (chassis.mass connects group chassis’s promoted mass).

  • assert constraint / requirement require with a comparison body -> a *_margin output (>= 0 iff the predicate holds), ready for add_constraint.

  • a calc def annotated @ExternalAnalysis { component = "module:attr"; } declares the I/O contract of a higher-fidelity tool wrapped as an OpenMDAO ExplicitComponent. When a derived attribute’s value is a direct invocation of such a calc, build_problem() can instantiate the referenced component instead of the interpreter-backed expression (fidelity={"CalcName": "external"}; bodiless annotated calcs bind externally by default), after validating the declared parameter names against the component’s actual inputs/outputs. The declared contract is the point: SysML owns the interface, the tool owns the physics, and both fidelities compose with the interpreter-backed components in one Problem.

Object-valued I/O (the ratified docs/design/mdao-objects.md) rides OpenMDAO’s stock discrete-variable machinery – no fork, no patched internals:

  • ENTITY BINDING: a part/item member typed by a variation definition becomes ONE discrete input carrying the configured M0 individual (longeron.m0.Individual – resolved attribute values, stable qname#index identity, definition backlink), instead of a scalar shred. The case being evaluated is an longeron.m0. Interpretation (build_problem(..., interpretation=...)); without one, a model that has variation points lazily materializes the implicit anonymous interpretation (first declared variants – the same choice the interpreter makes), and scalar-only models behave exactly as before. bind_entity() rebinds a variation point to another individual (qname or instance, conformance-checked, pickle-checked); entity_cases() turns a trade study’s variation points into om.ListGenerator-shaped DOE cases whose values are individuals. Homogeneous [n] multiplicities bind their (shared) per-unit individual; per-index heterogeneous selection is deferred with the trades phase-2 item.

  • RESULT RECORDING: record_case() returns a NEW immutable interpretation snapshot per case – the case’s individuals with the problem’s outputs written onto their slots (stable ids, JSON-clean to_dict). The input interpretation stays pristine; in-place Instance.set() remains available for interactive use but is outside the recorded lifecycle. case_values() feeds a snapshot straight into the scoreboard’s values= seam.

  • OBJECT FLOW: structured payloads (mesh dicts, cadquery recipes – never live kernel solids) move between components as discrete values, keyed by M0 individual id (geometry.tag_parts). Serial OpenMDAO passes discretes BY REFERENCE: payloads are frozen by convention (producers emit fresh dicts, consumers never mutate). Under MPI every discrete crossing a rank boundary is pickled – picklability is asserted at bind time with an error naming the offender.

  • FILE BOUNDARY: FileArtifact (path + sha256 + media type) flows as a tiny discrete value while the bytes stay on disk – ExternalCodeComp-compatible, recorder-friendly (its to_json is the lossless make_serializable hook), and the hash is the caching identity. write_artifact() / file_artifact() build one; artifact_component() wraps a writer callable as a boundary component. The matching SysML convention ships as examples/analysis_conventions.sysml (item def FileArtifact), so flows can be typed by it.

  • ITEM-FLOW WIRING: derive_flows() resolves a part’s flow of Payload from a.out to b.in usages against its action members and returns proposed OpenMDAO connections (endpoints validated, payload type checked against both ends); apply_flows() wires them. Propose + apply, never silent magic.

Requires the mdao extra: pip install "longeron[mdao]". OpenMDAO is imported lazily so the module can be imported (for docs, dir()) without it.

longeron.analysis.mdao.Fidelity

one calc’s evaluation route: its model body through the interpreter, or the wrapped higher-fidelity tool its @ExternalAnalysis annotation declares (build_problem(fidelity={"CalcName": "external"}))

alias of Literal[‘model’, ‘external’]

class longeron.analysis.mdao.FileArtifact(path, sha256, media_type='application/octet-stream')[source]

Bases: object

A file crossing the analysis boundary: a path plus content identity.

Flows as a tiny discrete value while the bytes stay on disk. The hash is the caching identity (same recipe, same hash – skip the external run) and the recorder-bloat fix: to_json is the hook OpenMDAO’s make_serializable tries first, so a recorded case reads back this record losslessly instead of megabytes of payload (or a silent class-name string). Consumers hand path to ExternalCodeComp’s external_input_files or their own subprocess. The matching SysML convention is the item def FileArtifact in examples/analysis_conventions.sysml (attributes path/sha256/mediaType), so flows can be typed by it.

class longeron.analysis.mdao.ProblemBuild(problem, independents=<factory>, derived=<factory>, constraints=<factory>, gaps=<factory>, externals=<factory>, disciplines=<factory>, entities=<factory>, interpretation=None, _interp=None, _target=None)[source]

Bases: object

A built (not yet run) OpenMDAO problem plus its SysML bookkeeping.

entities: dict[str, str]

entity discrete inputs: promoted name -> variation definition qname

interpretation: Interpretation | None = None

the case being evaluated (None until record_case() or a variation point asks – the design’s lazy implicit interpretation)

longeron.analysis.mdao.add_optimization(build, objective, design_vars, maximize=False, constraints=None)[source]

Configure SLSQP over margin constraints; call before setup().

Return type:

None

longeron.analysis.mdao.apply_flows(target, flows)[source]

Wire derived flow triples as connect() calls (before setup).

target is a ProblemBuild, an om.Problem, or a group; components are expected under the action names the endpoints use (build.mesh -> rcs.mesh connects component build’s mesh output to component rcs’s mesh input, discrete or continuous).

Return type:

None

longeron.analysis.mdao.artifact_component(write, directory, *, payload='payload', artifact='artifact', media_type='application/octet-stream', payload_default=None)[source]

A boundary component: write the payload to disk, emit the artifact.

write(payload_value, directory) produces the file (STEP, STL, JSON, …) and returns its path; the component hashes it and emits a FileArtifact as the artifact discrete output. This is the ExternalCodeComp-compatible pattern: downstream components (or the external code’s external_input_files) consume the path, the recorder sees ~200 bytes of JSON. Key directory per case (e.g. by interpretation id + case counter) so concurrent DOE cases never collide. payload_default types the discrete input’s default (OpenMDAO checks connection compatibility by isinstance on declared defaults; the default {} suits mesh/recipe dicts).

Return type:

Any

longeron.analysis.mdao.bind_entity(build, feature, entity)[source]

Rebind a variation point to an individual (the discrete case swap).

feature is a promoted entity input of the build (see ProblemBuild.entities); entity is either an Instance (typically an Individual from longeron.m0.interpret() or entity_cases()) or a qualified name resolved through the model – a catalog part def ("ScoutParts::TMotorMn4006") or a variant usage ("P::MotorChoice::light", which keeps its inline :>> redefinitions). The individual’s definition is checked for conformance against the variation point’s base type, and the payload must pickle (MPI ranks, case recording). Call between setup() and run_model(); homogeneous [n] members rebind their shared per-unit individual.

Return type:

None

longeron.analysis.mdao.build_problem(model, part, requirements=(), setup=True, fidelity=None, interpretation=None)[source]

Build an OpenMDAO Problem mirroring a part definition’s tree.

fidelity selects, per @ExternalAnalysis-annotated calc def (keyed by name or qualified name), whether a direct attribute x = Calc(...) value evaluates the calc’s first-order body through the interpreter ('model', the default when a body exists) or instantiates the annotated external component ('external'; the default – and only – choice when the calc declares no body). The two fidelities are drop-in replacements, so lo-fi/hi-fi swap studies are one keyword away.

interpretation is the M0 case being evaluated (longeron.m0.interpret()): free scalars seed from its slots, and every variation-typed part/item member becomes a discrete input carrying its configured individual (see bind_entity() to swap cases, record_case() to freeze results). Without it, scalar-only models behave exactly as before; a model that has variation points materializes the implicit anonymous interpretation lazily (first declared variants, zero ceremony).

Return type:

ProblemBuild

longeron.analysis.mdao.calc_component(interp, calc, out_name='result')[source]

Wrap a calc def as an ExplicitComponent (FD partials).

Return type:

Any

longeron.analysis.mdao.case_values(case)[source]

A scoreboard values= dict from a recorded case snapshot.

The snapshot’s root-level scalar (and boolean) slots, keyed by feature name – the same shape as architecture_values(), so scoreboard(model, values=case_values(snapshot)) scores a recorded case directly.

Return type:

dict[str, Any]

longeron.analysis.mdao.derive_flows(model, part)[source]

Resolved (source, target, payload qname) triples from a part’s flows.

Each flow of Payload from a.out to b.in usage is resolved against the part’s members through the resolver’s specialization walk (the same semantics as validate()’s dangling-flow / flow-payload-mismatch diagnostics): every endpoint must resolve, the source’s final hop must be an out/inout parameter, the target’s an in/inout parameter, and – when both the payload typing and the target end’s typing are KNOWN – some pair of them must be related by specialization in either direction. Violations raise AnalysisError naming the offending endpoint; unknown typing stays silent, exactly like the validator. Succession flows (control ordering, no payload) are skipped.

The triples are proposals: pass them to apply_flows() to wire an OpenMDAO problem whose component names mirror the action names. Nothing is connected implicitly.

Return type:

list[tuple[str, str, str | None]]

longeron.analysis.mdao.entity_cases(study, *points)[source]

DOE cases over a trade study’s catalog: one case per mix.

Walks the study’s variation points (all of them, or the named subset) and returns the full Cartesian product in om.ListGenerator shape – one [(point, individual), ...] list per case, where each value is the variant’s M0 individual (longeron.m0.interpret() of the variant usage, so inline :>> redefinitions are honored). Feed them to a DOEDriver after add_design_var()-ing each point:

cases = mdao.entity_cases(study, "motors", "props")
build.problem.model.add_design_var("motors")
build.problem.model.add_design_var("props")
build.problem.driver = om.DOEDriver(om.ListGenerator(cases))

The verify design’s covering arrays slot in later as another generator of the same currency: a population of interpretations.

Return type:

list[list[tuple[str, Any]]]

longeron.analysis.mdao.external_binding(calc)[source]

The @ExternalAnalysis component spec of a calc def, if any.

The annotation’s component value must be a string literal of the form 'package.module:attr' where attr is an om.ExplicitComponent subclass or a zero-argument factory returning one. Matching is by metadata-definition name (ExternalAnalysis), the convention shipped with the DeepScout program (examples/deepscout).

Return type:

str | None

longeron.analysis.mdao.file_artifact(path, media_type='application/octet-stream')[source]

A FileArtifact for an existing file (contents hashed).

Return type:

FileArtifact

longeron.analysis.mdao.record_case(build, outputs=None)[source]

A new interpretation snapshot: the case’s individuals + the outputs.

Call after run_model(). The build’s interpretation (created lazily when absent – the design’s implicit anonymous point) is copied, rebound entities are reflected (their positional individual ids stay stable across mixes), and every promoted output lands as an attribute value on the matching individual’s slot. outputs overrides the default set (independents + derived attributes + constraint margins, read from the problem). The result is an immutable-by-convention snapshot: the input interpretation stays pristine, re-recording never overwrites evidence, to_dict() is JSON-clean, and rollup()/case_values() work as usual. Values that fit no slot path are noted in the snapshot’s gaps.

Return type:

Interpretation

longeron.analysis.mdao.write_artifact(path, data, media_type='application/octet-stream')[source]

Write data (str encodes as UTF-8) and return its artifact.

Return type:

FileArtifact