This notebook introduces action def; after running it you can declare a named action with typed flows, a phenomena constraint, and nest it as a step of another action.
The Chapter 3 model expresses what the toaster must accomplish and evaluates a satisfaction claim against it, but says nothing about the steps by which the system does it. Chapter 4 adds the functional layer: named actions with typed flows and the relations that constrain them, composed into an actual decomposition of ToastBread from Chapter 1. This notebook adds ApplyHeat, an action def for the heating step, and nests it inside ToastBread as its first sub-action.
from pathlib import Path
import opensysml
from toaster.report import format_diagnostics
conn = opensysml.connect(version="v0.9.0")
# editor.add_action_def(owner='ToasterDemo', name='ApplyHeat') when API ships
ACTION_DEF_OPEN = "action def ApplyHeat {"
print(ACTION_DEF_OPEN)
action def ApplyHeat {
action def ApplyHeat opens the new action. Its parameters follow: first the inputs, then the outputs.
# params argument of add_action_def not yet supported, https://github.com/Open-MBEE/toaster/issues/18 / https://github.com/Open-MBEE/OpenSysML/issues/605
# spec: SysML v2 formal/2026-03-02 section 7.15 (ActionDefinition)
IN_PARAMS = """\
in bread : Bread;
in energy : ISQ::EnergyValue[0..*];
in duration : ISQ::DurationValue[0..*];"""
print(IN_PARAMS)
in bread : Bread;
in energy : ISQ::EnergyValue[0..*];
in duration : ISQ::DurationValue[0..*];
Three typed inputs: bread, the material Chapter 1 already defines; energy, the energy supplied to the action; and duration, a signal from a control function stating how long to apply heat. No control function is modeled in this chapter, so energy and duration are declared, typed, and left unconnected to any value; duration’s own doc states its denotation directly. Both carry an explicit [0..*] multiplicity, spelled out rather than left implicit; the next few cells build the rest of ApplyHeat before explaining why.
OUT_PARAMS = """\
out toast : Toast;
out delivered : ISQ::EnergyValue;
out loss : ISQ::EnergyValue;"""
print(OUT_PARAMS)
out toast : Toast;
out delivered : ISQ::EnergyValue;
out loss : ISQ::EnergyValue;
Three typed outputs: toast, the material Chapter 1 already defines; delivered, the energy actually delivered to the bread; and loss, the energy that is not. delivered and loss together are the two quantities the balance constraint bounds against energy.
# spec: SysML v2 formal/2026-03-02 section 7.20.1 (AssertConstraintUsage)
BALANCE_CONSTRAINT = """\
assert constraint balance {
delivered >= 0.0 [SI::J] and loss >= 0.0 [SI::J] and delivered + loss <= energy
}"""
print(BALANCE_CONSTRAINT)
assert constraint balance {
delivered >= 0.0 [SI::J] and loss >= 0.0 [SI::J] and delivered + loss <= energy
}
The constraint relates the three energy flows without choosing a mechanism: delivered energy and loss are each non-negative, and together they can never exceed the energy supplied. Asserting the constraint, rather than leaving it a plain constraint usage that SysML only allows to hold sometimes (7.20.1), is what makes that bound actually required of every usage of ApplyHeat, not merely checkable case by case. No specific efficiency is assumed and no conversion formula is stated; a chosen heating solution characterizes its own efficiency later, once one exists.
APPLY_HEAT_DEF = f"{ACTION_DEF_OPEN}\n{IN_PARAMS}\n{OUT_PARAMS}\n{BALANCE_CONSTRAINT}\n}}"
print(APPLY_HEAT_DEF)
action def ApplyHeat {
in bread : Bread;
in energy : ISQ::EnergyValue[0..*];
in duration : ISQ::DurationValue[0..*];
out toast : Toast;
out delivered : ISQ::EnergyValue;
out loss : ISQ::EnergyValue;
assert constraint balance {
delivered >= 0.0 [SI::J] and loss >= 0.0 [SI::J] and delivered + loss <= energy
}
}
ApplyHeat states typed flows and an asserted phenomena relation, but it is still a free-floating action, not a step of anything. The next fragment nests it inside ToastBread, the action Chapter 1 declared with no body, and binds applyHeat’s own bread input to ToastBread’s bread: the one flow actually available at this level. energy stays unbound; no energy source exists anywhere in the model yet.
# ToastBread (Chapter 1) declared bread/toast but no body; this fragment adds one.
TOASTBREAD_SEQUENCE = """\
first start;
then action applyHeat : ApplyHeat {
in bread = ToastBread::bread;
}
then done;"""
print(TOASTBREAD_SEQUENCE)
first start;
then action applyHeat : ApplyHeat {
in bread = ToastBread::bread;
}
then done;
ToastBread’s own doc and its bread/toast parameters are restated unchanged; the sequence is new, and it wires the one flow this level actually has. The full increment for this notebook is ApplyHeat’s declaration together with ToastBread’s reopened body.
TOASTBREAD_REOPENED = (
"action def ToastBread {\n"
" doc /* Transform bread into toast acceptable to its user. */\n"
" in bread : Bread;\n"
" out toast : Toast;\n"
f"{TOASTBREAD_SEQUENCE}\n"
"}"
)
TOASTER_INCREMENT = f"{APPLY_HEAT_DEF}\n{TOASTBREAD_REOPENED}"
source = Path("../../models/ch04-cumulative.sysml").read_text()
model = conn.load_from_content(source, strict=False)
assert model.ok, f"Model failed: {format_diagnostics(model.diagnostics)}"
With the increment loaded, draw its sequence directly from the model:
from toaster.render import render_action_flow
from IPython.display import SVG
out_path = Path("../../figures/ch04-toastbread-flow.svg")
out_path.parent.mkdir(exist_ok=True)
render_action_flow(model, "ToasterDemo::ToastBread", out_path)
SVG(filename=str(out_path))The start → applyHeat → done sequence drawn directly from the loaded model: the same nesting the text above states, shown as a flow.
The reopened ToastBread and the new ApplyHeat load without diagnostics. Nesting ApplyHeat this way is what makes ToastBread an actual decomposition, and it is why energy and duration are written with an explicit [0..*] above rather than left bare. A bare, unstated multiplicity and writing [0..*] out explicitly should mean the same thing (SysML v2.0 formal/2026-03-02 7.6.3, 7.6.4: a keyword-less in/out parameter like these already defaults to [0..*]), but the OpenSysML runtime v0.9.0 treats them differently: left implicit, any attribute of a Toaster usage that reaches ApplyHeat through ToastBread fails to evaluate; written out, exactly the same model evaluates cleanly (DEFERRED.md D-026). Spelling out the multiplicity here states the model’s real, spec-default meaning honestly and keeps it fully evaluable, working around a real inconsistency in the OpenSysML runtime without claiming anything new about energy or duration.
The next cell checks what happens when a nested step names an action definition that does not exist.
# Negative control: a nested step naming an undefined action definition
# raises "unresolved reference" at the reference site.
bad_source = """
package Bad {
action def BadParent {
first start;
then action child : UndefinedAction;
then done;
}
}
"""
bad = conn.load_from_content(bad_source, strict=False)
assert not bad.ok
print("Expected error:", bad.diagnostics[0].message)
Expected error: unresolved reference: UndefinedAction
With the negative control confirmed, the next cell looks up ApplyHeat and confirms it resolves as a nested step of ToastBread, with its balance constraint present as a named member, and confirms the model stays fully evaluable: slow’s cycleTime, unrelated to ApplyHeat, still evaluates to the 200-second value Chapter 2 gave it.
action = model.find("ToasterDemo::ApplyHeat")
assert action is not None
print(f"action kind: {action.kind}")
step = model.find("ToasterDemo::ToastBread::applyHeat")
assert step is not None
print(f"nested step kind: {step.kind}")
balance = model.find("ToasterDemo::ApplyHeat::balance")
assert balance is not None
print(f"balance constraint kind: {balance.kind}")
slow_cycle_time = model.eval("ToasterDemo::slow.cycleTime")
print(f"slow.cycleTime: {slow_cycle_time}")
conn.close()
action kind: actionDef
nested step kind: actionUsage
balance constraint kind: constraintUsage
slow.cycleTime: 200 [SI::s]
action def ApplyHeat { ... assert constraint balance { ... } } and ToastBread’s reopened body, printed above, loaded without error, and model.find() resolves ApplyHeat as ToastBread’s own step and its balance constraint by name, shown by the kinds printed above. model.find(...).kind reports the general constraint kind, not the asserted subtype: the printed constraintUsage is not a contradiction of assert constraint, just a coarser label than the model’s own declaration. slow.cycleTime evaluating to 200 seconds confirms the explicit [0..*] above keeps the whole model evaluable, not just ApplyHeat itself.
Try the chapter exercise in exercises/ch04/exercise.ipynb: declare a new action def (not action def Brew — Chapter 1 already declares that at package level, so redeclaring it collides) for your coffee maker’s BrewUnit, with typed in/out flows and a first/then sequence nesting its usage inside Brew’s own reopened body, mirroring ApplyHeat and ToastBread.