5. Individuals: populations, not possibilities

The question: the M1 model declares motors : MotorChoice[4]. Which four motors exist on the aircraft you fly, and what do they weigh together?

Every tutorial so far worked at M1, the model level. The declaration above describes an aircraft that has four motor stations. M0 is the level below: the four actual motors that one particular aircraft has. longeron.m0 builds M0 populations directly on the interpreter. An Interpretation is a set of Individual instances with stable qname#index ids. Every query below runs over those individuals, not over the model’s descriptions.

You will learn how to:

  • build a nominal interpretation of tutorial 4’s ISR winner and address every individual by id;

  • read features as sequences, the KerML Annex A semantics;

  • roll metrics up over the actual population and catch a hand-encoded population shortcut;

  • draw seeded random populations and Monte-Carlo the catalog;

  • read a recorded execution and a trade-study architecture as interpretations;

  • pass individuals and file artifacts across the OpenMDAO bridge.

Prerequisites: tutorial 2 for execution and tutorial 4 for the catalog and its trade studies. longeron.m0 is stdlib-only. Only the trade-study and bridge cells use longeron.analysis.

import longeron
from longeron import m0

model = longeron.load("../examples/deepscout")

ISR_MIX = {  # the hover-capable ISR reference bird (frozen as ScoutSizing::IsrPrime)
    "airframe": "vtolWing",
    "motors": "mn4006",
    "props": "apc11x55",
    "battery": "liion6s6p",
    "sensor": "zenmuseH20",
    "material": "carbonFiber",
}
isr = m0.interpret(model, "ScoutMissions::IsrUav", selection=ISR_MIX)
print("root:   ", isr.root)
print("battery:", isr.root.slots["battery"].id, " (a singleton omits the index)")
for motor in isr.root.slots["motors"]:
    print(f"  {motor.id}  mass={motor.slots['mass']} kg  maxThrust={motor.slots['maxThrust']} N")
sensor = isr.root.slots["sensor"]
print("sensor: ", sensor.id, f" ({sensor.slots['mass']} kg of stabilized optics)")
motor_count = len(isr.individuals("ScoutParts::Motor"))
print("individuals:", len(isr.individuals()), " of which motors:", motor_count)
root:    <ScoutMissions::IsrUav#0: ScoutMissions::IsrUav>
battery: ScoutMissions::IsrUav#0.battery  (a singleton omits the index)
  ScoutMissions::IsrUav#0.motors#0  mass=0.068 kg  maxThrust=20.0 N
  ScoutMissions::IsrUav#0.motors#1  mass=0.068 kg  maxThrust=20.0 N
  ScoutMissions::IsrUav#0.motors#2  mass=0.068 kg  maxThrust=20.0 N
  ScoutMissions::IsrUav#0.motors#3  mass=0.068 kg  maxThrust=20.0 N
sensor:  ScoutMissions::IsrUav#0.sensor  (0.68 kg of stabilized optics)
individuals: 16  of which motors: 4

The answer up front: weigh the individuals

Tutorial 4’s ISR study ended with this winner: the winged VTOL with Antigravity motors, 11-inch props, the li-ion pack, the H20 gimbal, and a carbon spar. The cell above built the aircraft that mix describes. m0.interpret expands exact multiplicities fully, and ranges take their lower bound. Every individual gets a stable qname#index id. The population holds four distinct motor individuals, so motors#3 is a thing an engineer can point at, not a count.

rollup answers the opening question. Feature references resolve against the root individual’s slots. sum(motors.mass) adds the four real motors, and size(motors) counts them. No count is hand-encoded anywhere.

print(f"{'feature':10s}{'count':>6s}{'together':>12s}")
for feature in ("motors", "props"):
    count = isr.rollup(f"size({feature})")
    total = isr.rollup(f"sum({feature}.mass)")
    print(f"{feature:10s}{count:6d}{total:9.3f} kg")

per_unit = isr.root.slots["motors"][0].slots["mass"]
assert isr.rollup("size(motors)") == 4
assert isr.rollup("sum(motors.mass)") == 4 * per_unit
print(f"\nfour motors exist, and together they weigh {isr.rollup('sum(motors.mass)'):.3f} kg")
feature    count    together
motors         4    0.272 kg
props          4    0.088 kg

four motors exist, and together they weigh 0.272 kg

A feature reads as a set of sequences (KerML Annex A)

Annex A of the KerML specification gives features their formal meaning. A feature is a set of sequences, and each sequence starts with an individual of the featuring type. sequences("motors") yields one (aircraft, motor) pair per motor station. The nested sequences("motors.mass") extends each pair by one step, down to the value.

for seq in isr.sequences("motors")[:2]:
    print(seq)
print("...\n")
for owner, motor, mass in isr.sequences("motors.mass"):
    print(f"({owner.id}, {motor.id}, {mass})")
(<ScoutMissions::IsrUav#0: ScoutMissions::IsrUav>, <ScoutMissions::IsrUav#0.motors#0: ScoutParts::TMotorMn4006>)
(<ScoutMissions::IsrUav#0: ScoutMissions::IsrUav>, <ScoutMissions::IsrUav#0.motors#1: ScoutParts::TMotorMn4006>)
...

(ScoutMissions::IsrUav#0, ScoutMissions::IsrUav#0.motors#0, 0.068)
(ScoutMissions::IsrUav#0, ScoutMissions::IsrUav#0.motors#1, 0.068)
(ScoutMissions::IsrUav#0, ScoutMissions::IsrUav#0.motors#2, 0.068)
(ScoutMissions::IsrUav#0, ScoutMissions::IsrUav#0.motors#3, 0.068)

One model, five populations: the MultiRotor family

The program’s MultiRotor branch declares one abstract MultiRotor and five configurations. The configurations differ by their redefined rotor populations. The quad declares motors : Motor[4]. The tricopter declares frontMotors : Motor[2] plus a single tailMotor. The hexa declares motors : Motor[6] and the flat octo motors : Motor[8]. The coax X8 declares upperMotors : Motor[4] plus lowerMotors : Motor[4]. The redefined multiplicities are the architecture difference.

The cell below interprets all five configurations. Each declaration fans out into its own population: 4, 3, 6, 8, and 8 motor individuals. The same rollup queries weigh every population side by side, and no count is hand-encoded anywhere. On the X8, upperMotors#0 and lowerMotors#0 are one coaxial pair: two individuals an engineer can point at, on one arm. Tutorial 4 trades this family. This section shows that populations scale past the toy case.

drone_family = longeron.load("../examples/deepscout")
ROTOR_FEATURES = {
    "QuadCopter": ("motors",),
    "TriCopter": ("frontMotors", "tailMotor"),
    "HexaCopter": ("motors",),
    "OctoCopter": ("motors",),
    "CoaxX8": ("upperMotors", "lowerMotors"),
}
fan_out = {}
print(f"{'config':12s}{'declared rotor populations':<28s}{'motors':>7s}{'rotor kg':>9s}")
for config, features in ROTOR_FEATURES.items():
    population = m0.interpret(drone_family, f"Rotorcraft::{config}")
    assert not population.gaps  # every roll-up evaluates over the population
    fan_out[config] = len(population.individuals("ScoutParts::F450Kit::Motor"))
    mass = sum(population.rollup(f"sum({feature}.mass)") for feature in features)
    print(f"{config:12s}{' + '.join(features):<28s}{fan_out[config]:7d}{mass:9.3f}")

assert fan_out == {"QuadCopter": 4, "TriCopter": 3, "HexaCopter": 6, "OctoCopter": 8, "CoaxX8": 8}
x8 = m0.interpret(drone_family, "Rotorcraft::CoaxX8")
pair = (x8.root.slots["upperMotors"][0].id, x8.root.slots["lowerMotors"][0].id)
print("\none coaxial pair, two individuals:", *pair)
config      declared rotor populations   motors rotor kg
QuadCopter  motors                            4    0.220
TriCopter   frontMotors + tailMotor           3    0.165
HexaCopter  motors                            6    0.330
OctoCopter  motors                            8    0.440
CoaxX8      upperMotors + lowerMotors         8    0.440

one coaxial pair, two individuals: Rotorcraft::CoaxX8#0.upperMotors#0 Rotorcraft::CoaxX8#0.lowerMotors#0

Roll-ups weigh what exists, and gaps stay honest

Look at how the catalog builds its mass ledger: airframe.motorCount * (motors.mass + props.mass). That expression hand-encodes a population as a scale factor. Tutorial 4’s trades machinery evaluates it by treating motors as one prototype and multiplying. Over a real population, motors.mass is four values. A count times a list is not an answer.

The ratified design decision keeps the divergence honest. When an M1 expression refuses over a population, the slot degrades to None and the reason lands in Interpretation.gaps. Nothing raises. The population still builds, and a strict caller asserts gaps == [].

print("baseMass slot:      ", isr.root.slots["baseMass"])
print("stationMinutes slot:", isr.root.slots["stationMinutes"])
print("\ngaps -- the mission's own metrics, each with its reason:")
for gap in isr.gaps:
    print("  ", gap)
assert isr.gaps, "the M1 ledger cannot price a population, so gaps must record why"
baseMass slot:       None
stationMinutes slot: None

gaps -- the mission's own metrics, each with its reason:
   missionMass: cannot apply '+' to None and 0.68
   missionCost: cannot apply '+' to None and 4600.0
   hoverPowerW: cannot apply '*' to None and 9.81
   loiterPowerW: cannot apply '*' to [0.82, 0.82, 0.82, 0.82] and [0.85, 0.85, 0.85, 0.85]
   stationMinutes: cannot apply '*' to None and 120.0

The trap: two hand-encodings of one population disagree

Every MissionUAV declares motors : MotorChoice[4]. The DartInterceptor mounts one pusher motor. The model keeps the [4] declaration and patches the ledger with airframe.motorCount = 1. At M1 the patch works, so tutorial 4 scored the dart correctly. Build the population and the contradiction turns physical. The interpretation expands [4] into four SprintMotor individuals on an aircraft that mounts one.

The cell below prints two confident and different masses for the same mix. The M1 number scales one prototype by motorCount. The M0 number weighs the declared individuals. The two ledgers split by a full kilogram, because each phantom AT4120 weighs 320 grams.

Which number is right? Here the M1 number is right, but only the M0 population forces the question. The honest fix is a model fix: declare motors : MotorChoice[1] for the dart, or derive motorCount from size(motors).

from longeron.analysis.trades import TradeStudy

intercept = TradeStudy(model, "ScoutMissions::InterceptUav")
fastest = max(
    (a for a in intercept.all_architectures() if a.verified),
    key=lambda a: a.metrics["maxTargetSpeed"],
)
print("fastest feasible mix:", fastest.selection)

dart = m0.interpret(model, "ScoutMissions::InterceptUav", selection=fastest.selection)
stations = dart.root.slots["motors"]
print("declared stations:  ", [s.id.rsplit(".", 1)[-1] for s in stations])
print("motor type:", stations[0].type_name, end="   ")
print("airframe.motorCount:", dart.root.slots["airframe"].slots["motorCount"], "\n")

SPAR_MASS = (  # the wing spar, sized from loads in the selected material
    "TubeMass(radius = airframe.sparRadius, wall = max(minWallM,"
    " TubeWallForStress(momentNm = SparRootMoment(grossKg = airframe.designGrossKg,"
    " span = airframe.wingSpan), radius = airframe.sparRadius,"
    " yieldPa = material.yieldPa)), length = airframe.wingSpan,"
    " density = material.density)"
)
DART_LEDGER = (
    "airframe.mass + avionicsMass + battery.mass + "
    + SPAR_MASS
    + " + sum(motors.mass) + sum(props.mass) + seekerMass"
)
m1_mass = fastest.metrics["missionMass"]
m0_mass = dart.rollup(DART_LEDGER)
print(f"M1 mission mass (motorCount x per-unit):  {m1_mass:.3f} kg")
print(f"M0 mission mass (sum over individuals):   {m0_mass:.3f} kg")
print(f"three phantom motors and props:           {m0_mass - m1_mass:+.3f} kg\n")
for gap in dart.gaps[:3]:
    print("  ", gap)
print("   ...")
fastest feasible mix: {'airframe': 'dartInterceptor', 'motors': 'at4120', 'props': 'tm15x5', 'battery': 'tattu16000', 'material': 'aluminum'}
declared stations:   ['motors#0', 'motors#1', 'motors#2', 'motors#3']
motor type: ScoutParts::TMotorAt4120   airframe.motorCount: 1 

M1 mission mass (motorCount x per-unit):  3.702 kg
M0 mission mass (sum over individuals):   4.755 kg
three phantom motors and props:           +1.053 kg

   missionMass: cannot apply '+' to None and 0.12
   missionCost: cannot apply '+' to None and 260.0
   dashSpeed: cannot apply '*' to [2000.0, 2000.0, 2000.0, 2000.0] and [0.87, 0.87, 0.87, 0.87]
   ...

Nominal takes the lower bound, random explores the range

The catalog pins its multiplicities at [4], so a small excerpt of the drone program introduces the ranged case. The field kit below packs rotors : Rotor[2..6] and spares : Rotor[0..*]. Under the nominal strategy, both features take their lower bound, the same deterministic choice instantiate() makes. Under strategy="random", the draw picks each population size uniformly within its bounds. An unbounded upper bound is capped at the lower bound plus 3. The draw also samples unvalued enum and Boolean attributes from their literal domains.

Seeds make every draw reproducible. Equal seeds reproduce equal populations. sample(n) derives n fresh interpretations from the parent seed. The next section takes this machinery back to the catalog.

FIELD_KIT = """
package FieldKit {
    enum def Livery { plain; racing; stealth; }
    part def Rotor {
        attribute mass : Real = 0.06;
        attribute livery : Livery;
    }
    part def FieldQuad {
        part rotors : Rotor[2..6];
        part spares : Rotor[0..*];
    }
}
"""
kit = longeron.loads(FIELD_KIT)


def shape(it):
    return f"{len(it.root.slots['rotors'])} rotors, {len(it.root.slots['spares'])} spares"


print("nominal:", shape(m0.interpret(kit, "FieldKit::FieldQuad")))
drawn = m0.interpret(kit, "FieldKit::FieldQuad", strategy="random", seed=7)
print("seed 7: ", shape(drawn))
print("liveries:", [rotor.slots["livery"].name for rotor in drawn.root.slots["rotors"]])
for s in drawn.sample(3):
    print(f"  sample seed {s.seed}: {shape(s)}")
rerun = m0.interpret(kit, "FieldKit::FieldQuad", strategy="random", seed=7)
assert rerun.to_dict() == drawn.to_dict()
print("equal seeds reproduce equal populations:", rerun.to_dict() == drawn.to_dict())
nominal: 2 rotors, 0 spares
seed 7:  4 rotors, 0 spares
liveries: ['plain', 'racing', 'stealth', 'plain']
  sample seed 1390851128: 5 rotors, 0 spares
  sample seed 647892279: 5 rotors, 3 spares
  sample seed 1695753998: 3 rotors, 3 spares
equal seeds reproduce equal populations: True

Monte-Carlo over the catalog: 64 aircraft nobody enumerated

strategy="random" on IsrUav draws every variation point. It also draws the motor and prop stations per individual. motors#0 can be a sprint motor while motors#1 is an eco motor. The M1 convention cannot score such a heterogeneous aircraft, because no single prototype scales by motorCount. The M0 ledger does not care. Each drawn aircraft is a population. The roll-up weighs whatever hangs on it.

The cell below makes 64 seeded draws and plots the equipped hardware mass. The ledger sums the shell, the avionics, the pack, the sized spar, the stations, and the sensor. Look for the spread inside each airframe family. The within-family spread is wider than the spread between family means.

from random import Random
from statistics import mean, stdev

import matplotlib.pyplot as plt

HARDWARE_MASS = (
    "airframe.mass + avionicsMass + battery.mass + "
    + SPAR_MASS
    + " + sum(motors.mass) + sum(props.mass) + sensor.mass"
)
parent = m0.interpret(model, "ScoutMissions::IsrUav", strategy="random", seed=2025)
fleet64 = [parent, *parent.sample(63)]
by_family: dict[str, list[float]] = {}
for it in fleet64:
    by_family.setdefault(it.selection["airframe"], []).append(it.rollup(HARDWARE_MASS))
masses = [mass for family in by_family.values() for mass in family]
print(
    f"64 seeded draws: {min(masses):.2f} .. {max(masses):.2f} kg,"
    f" mean {mean(masses):.2f}, sigma {stdev(masses):.2f}"
)

jitter = Random(0)
order = sorted(by_family, key=lambda f: mean(by_family[f]))
fig, ax = plt.subplots(figsize=(7.0, 3.0), layout="constrained")
for row, family in enumerate(order):
    xs = by_family[family]
    ys = [row + jitter.uniform(-0.18, 0.18) for _ in xs]
    ax.plot(xs, ys, "o", color="#2f6b8f", markersize=4.5, alpha=0.55, markeredgewidth=0)
    ax.plot(mean(xs), row, "|", color="#c2603e", markersize=18, markeredgewidth=2.2)
ax.set_yticks(range(len(order)), [f"{f} ({len(by_family[f])})" for f in order])
ax.set_xlabel("equipped hardware mass (kg)")
ax.set_title(
    "64 seeded draws over the ISR catalog: equipment choices outweigh the airframe",
    fontsize=10,
    loc="left",
    color="#2b2d31",
)
for side in ("top", "right", "left"):
    ax.spines[side].set_visible(False)
ax.grid(axis="x", color="#d9dbdf", linewidth=0.5)
64 seeded draws: 2.75 .. 8.03 kg, mean 4.71, sigma 1.18
../_images/2ea294387fc32d9f5629b1ee35cf81d79373e95f82ce665b35936c6a61bedbef.png

That spread is a design input. Shared infrastructure must cover the envelope of 2.6 to 7.3 kg, not the 4.6 kg mean. Transport cases, launch rails, chargers, and spare packs all size against the envelope.

Most of the spread is configuration spread. Freeze the airframe, and the mass still spans about 3 kg, because the pack and sensor draws dominate. The catalog already carries the consequence. The vtolWing spar sizes against designGrossKg = 6.0, the heavy corner of its band, not the nominal build.

Traces are interpretations

A recorded execution is an interpretation of the behavior. pymbe, the reference implementation for population semantics, describes individuals but executes nothing. longeron executes state machines, so a recorded run becomes a population too. from_timeline turns each contiguous state activation of the drone’s FlightStates state machine into an occurrence individual. Each occurrence individual carries start, end, and duration slots. Tutorial 2 executes this state machine.

Read the output as a population of five occurrence individuals in activation order. The second visit to idle gets a fresh identity, @1. A catalog motor and a recorded occurrence individual are the same Individual class. Only their slots differ: a datasheet against a lifetime. rollup and sequences work unchanged, so sum(occurrences.duration) is the same operation as sum(motors.mass).

from longeron.replay import record_timeline

drone = longeron.load("../examples/deepscout")
interp = longeron.Interpreter(drone)
flight = record_timeline(
    interp,
    "DeepScout::FlightStates",
    [1.5, "launch", 2.0, "airborne", 10.0, "low_battery", 1.0, "touchdown"],
)
trace = m0.from_timeline(flight, source="DeepScout::FlightStates")
print("strategy:", trace.strategy, " recording span:", trace.root.slots["duration"], "s\n")
for occ in trace.root.slots["occurrences"]:
    start, end = occ.slots["start"], occ.slots["end"]
    print(f"  {occ.id:36s} {start:5.1f} -> {end:5.1f}  ({occ.slots['duration']:4.1f} s)")
print("\nidle re-entries:", [ind.id for ind in trace.individuals("DeepScout::FlightStates::idle")])
print("sum(occurrences.duration):", trace.rollup("sum(occurrences.duration)"))

static = isr.root.slots["motors"][2]
occurrence = trace.individuals("DeepScout::FlightStates::flying")[0]
assert type(static) is type(occurrence) is m0.Individual
print("one class, two kinds of individual:", type(static).__name__)
strategy: trace  recording span: 14.5 s

  DeepScout::FlightStates::idle@0        0.0 ->   1.5  ( 1.5 s)
  DeepScout::FlightStates::takingOff@0   1.5 ->   3.5  ( 2.0 s)
  DeepScout::FlightStates::flying@0      3.5 ->  13.5  (10.0 s)
  DeepScout::FlightStates::landing@0    13.5 ->  14.5  ( 1.0 s)
  DeepScout::FlightStates::idle@1       14.5 ->  14.5  ( 0.0 s)

idle re-entries: ['DeepScout::FlightStates::idle@0', 'DeepScout::FlightStates::idle@1']
sum(occurrences.duration): 14.5
one class, two kinds of individual: Individual

A trades architecture is a partial interpretation

Tutorial 4 enumerated 5760 ISR mixes by pinning every variation point and scoring the result through the interpreter. That pinning is a partial M0 interpretation: variant selection fixed, population nominal. from_architecture makes the reading literal.

The cell below re-derives the reference bird: the longest-station mix among the hover-capable airframes (the tailless flying wings of 0.12 out-loiter every one of them, but this tutorial’s population studies stay on the bird tutorial 4 froze as IsrPrime). Its selection equals ISR_MIX from the first cell. The interpretation built from the architecture equals the one built by hand, to_dict() for to_dict(). An architecture does not resemble an interpretation. It denotes the same population.

study = TradeStudy(model, "ScoutMissions::IsrUav")
architectures = study.all_architectures()
feasible = [a for a in architectures if a.verified]
print(len(architectures), "mixes,", len(feasible), "feasible")

top = max(feasible, key=lambda a: a.metrics["stationMinutes"])
print("longest-station mix overall:", top.selection)  # a tailless flying wing since 0.12
winner = max(
    (a for a in feasible if a.selection["airframe"] == "vtolWing"),
    key=lambda a: a.metrics["stationMinutes"],
)
print("longest-station hover-capable mix:", winner.selection)
assert winner.selection == ISR_MIX
print("The reference bird is the mix from the first cell:", winner.selection == ISR_MIX)

isr_m0 = m0.from_architecture(study, winner)
assert isr_m0.to_dict() == isr.to_dict()
print("same population, individual for individual:", isr_m0.to_dict() == isr.to_dict())
5760 mixes, 362 feasible
longest-station mix overall: {'sensor': 'zenmuseH20', 'airframe': 'flyingWingSingle', 'motors': 'mn4006', 'props': 'apc11x55', 'battery': 'liion6s6p', 'material': 'carbonFiber'}
longest-station hover-capable mix: {'sensor': 'zenmuseH20', 'airframe': 'vtolWing', 'motors': 'mn4006', 'props': 'apc11x55', 'battery': 'liion6s6p', 'material': 'carbonFiber'}
The reference bird is the mix from the first cell: True
same population, individual for individual: True

The regression: individuals reproduce the trades metrics

Rewrite each ISR metric over the actual population. Per-unit values come back as sum(x) / size(x), so no count is hand-encoded anywhere. Each rewritten metric must equal the number the trades machinery computed at M1. The cell below asserts four exact matches, including the full physics chain: hover reserve, wing-borne loiter power, and the sized carbon spar. tests/test_m0.py pins the same discipline across the quad-copter catalog, so the two semantics cannot drift apart unnoticed.

import math

DISK_AREA = (
    "airframe.diskAreaFactor * 3.141592653589793"
    " * pow(sum(props.diameter) / size(props), 2.0) / 4.0"
)
HOVER = f"HoverPower(massKg = {HARDWARE_MASS}, diskArea = {DISK_AREA})"
LOITER = (
    f"CruisePower(massKg = {HARDWARE_MASS}, speed = airframe.loiterSpeed,"
    " dragArea = airframe.dragArea, span = airframe.wingSpan,"
    " wingArea = airframe.wingArea,"
    " spanEff = airframe.oswald * airframe.tipPropBonus,"
    " propEff = sum(props.cruiseEff) / size(props)"
    " * sum(motors.efficiency) / size(motors))"
)
ROLLUPS = {
    "missionMass": HARDWARE_MASS,
    "missionCost": (
        "airframe.cost + avionicsCost + battery.cost"
        f" + ({SPAR_MASS}) * material.costPerKg"
        " + sum(motors.cost) + sum(props.cost) + sensor.cost"
    ),
    "maxThrust": "sum(motors.maxThrust)",
    "stationMinutes": (
        f"(usableEnergyJ - ({HOVER}) * airframe.hoverOpsSec) / (({LOITER}) + sensor.powerW) / 60.0"
    ),
}
print(f"{'metric':16s}{'trades (M1)':>14s}{'roll-up (M0)':>14s}")
for metric, expr in ROLLUPS.items():
    value = isr_m0.rollup(expr)
    assert math.isclose(value, winner.metrics[metric], rel_tol=1e-12)
    print(f"{metric:16s}{winner.metrics[metric]:14.3f}{value:14.3f}")
metric             trades (M1)  roll-up (M0)
missionMass              5.437         5.437
missionCost           6702.115      6702.115
maxThrust               80.000        80.000
stationMinutes         200.351       200.351

Objects across the bridge: entities and file artifacts

Scalars are not the only values that cross into OpenMDAO. Pass an interpretation to build_problem and each variation point becomes a discrete input that carries its configured individual. The build stands directly on IsrUav and needs no hand-frozen IsrPrime. bind_entity swaps a case in place: hand the problem a different motor and the whole sizing chain re-evaluates. record_case freezes the evaluated case as an immutable interpretation snapshot. Outputs land as attribute values on the case’s individuals, with stable ids and roll-ups over the recorded population.

Files cross the same way. A FileArtifact is a path plus a sha256 content identity. It flows between components as a small discrete value while the bytes stay on disk. The hash is the caching identity: same recipe, same hash, skip the external run. The payload stays M0-keyed, so the individual id says which configured part a file belongs to. The design rationale lives in docs/design/mdao-objects.md.

import json
import tempfile
from pathlib import Path

from longeron.analysis import mdao

case = m0.interpret(model, "ScoutMissions::IsrUav", selection=ISR_MIX)
entity_build = mdao.build_problem(model, "ScoutMissions::IsrUav", interpretation=case)
ep = entity_build.problem
ep.run_model()
print("entity inputs:", ", ".join(sorted(entity_build.entities)))
print(
    f"stationMinutes ({case.selection['motors']}):",
    round(float(ep.get_val("stationMinutes")[0]), 1),
    "min",
)
mdao.bind_entity(entity_build, "motors", "ScoutParts::SunnySkyX4112s")
ep.run_model()
print("stationMinutes (x4112s):   ", round(float(ep.get_val("stationMinutes")[0]), 1), "min")
snapshot = mdao.record_case(entity_build)
print(
    "recorded case:",
    snapshot.root.id,
    "| motors =",
    snapshot.selection["motors"],
    "| motor mass roll-up:",
    round(snapshot.rollup("sum(motors.mass)"), 3),
    "kg",
)

recorded = Path(tempfile.mkdtemp()) / "recorded_case.json"
recorded.write_text(json.dumps(snapshot.to_dict()))
artifact = mdao.file_artifact(recorded, media_type="application/json")
print("\nfile artifact:", recorded.name, " sha256:", artifact.sha256[:12], "...")
entity inputs: airframe, battery, material, motors, props, sensor
stationMinutes (mn4006): 200.4 min
stationMinutes (x4112s):    186.7 min
recorded case: ScoutMissions::IsrUav#0 | motors = SunnySkyX4112s | motor mass roll-up: 0.732 kg

file artifact: recorded_case.json  sha256: e6a048d40a34 ...

The JSON shape stays out of the standard API

to_dict() projects an interpretation into plain JSON-able data: ids, selection, gaps, and the full slot tree. The projection is a deliberate longeron extension. The OMG Systems Modeling API has no M0 representation, so to_api_json never emits interpretations. The standard record stream stays clean for ecosystem consumers. Tutorial 1 walks that standard projection. If interpretations are ever served over HTTP, they enter through an extension namespace.

payload = isr.to_dict()
print("keys:", list(payload))
print(json.dumps(payload["root"]["motors"][0], indent=2))
assert json.loads(json.dumps(payload)) == payload
print("JSON round-trip:", json.loads(json.dumps(payload)) == payload)
keys: ['source', 'strategy', 'seed', 'selection', 'gaps', 'root']
{
  "@id": "ScoutMissions::IsrUav#0.motors#0",
  "@type": "ScoutParts::TMotorMn4006",
  "mass": 0.068,
  "cost": 96.0,
  "maxThrust": 20.0,
  "maxPowerW": 420.0,
  "efficiency": 0.85,
  "maxPropDiameter": 0.46,
  "cells": 6
}
JSON round-trip: True

The M0 story in one line

One Individual representation covers the catalog’s part trees, seeded Monte-Carlo draws, recorded occurrence individuals, and trade-study architectures. rollup and sequences are the single query surface, and gaps is the honesty channel. The design rationale and the ratified decisions live in docs/design/m0-interpretations.md. Tutorial 6 turns from populations to judgment: it scores the fleet and hunts for the mixes that break.