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
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.