2. The model executes

The drone is a stock F450-class build from real parts: EMAX MT2213 motors, APC 10x4.5MR propellers, a Tattu 5200 mAh 3S pack. Their nominal datasheet figures back a claimed max cruise speed of 20.0 m/s. Can the model compute that number?

It can, because the model carries its own physics as calc definitions. PropThrust computes rotor thrust from motor and propeller parameters. MaxTilt converts thrust into a tilt ceiling. MaxCruiseSpeed converts tilt into speed. The Interpreter evaluates them, checks constraints and requirements, runs actions, and simulates state machines.

Tutorial 1 explains the model tree that this notebook executes. The first cell instantiates the drone and prints the whole derivation, from mass to speed.

import longeron

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

drone = interp.instantiate("Rotorcraft::QuadCopter")
for name, unit in [
    ("totalMass", "kg"),
    ("thrustPerRotor", "N static, per rotor"),
    ("usableThrust", "N continuous, all rotors"),
    ("maxTilt", "deg, physics ceiling"),
    ("cruiseTilt", "deg, commanded"),
    ("maxCruiseSpeed", "m/s"),
]:
    print(f"{name:16s} {drone.slots[name]:8.2f}  {unit}")

assert abs(drone.slots["maxCruiseSpeed"] - 20.0) < 0.05
print("\nthe datasheet number, derived from mass, thrust, and drag")
totalMass            1.41  kg
thrustPerRotor       8.32  N static, per rotor
usableThrust        19.64  N continuous, all rotors
maxTilt             45.24  deg, physics ceiling
cruiseTilt          25.00  deg, commanded
maxCruiseSpeed      19.97  m/s

the datasheet number, derived from mass, thrust, and drag

Rebuild the number from the bottom

evaluate runs one KerML expression with keyword bindings for free names. The cell below first computes the tilt that level flight at 20.0 m/s demands. The result lands within a tenth of a degree of the drone’s commanded cruiseTilt of 25 degrees.

call invokes a calc definition as a Python function. The same cell then walks the model’s own chain from thrust to tilt to speed. The chain ends at the number the drone instance already carries, and the assert proves the match. If a parameter with a default stays unbound, the default applies. HoverTime shows that with its default hover current.

tilt_needed = interp.evaluate(
    "atan(0.5 * 1.225 * cda * v * v / (m * 9.81)) * 180.0 / pi",
    cda=drone.slots["dragArea"],
    v=20.0,
    m=drone.slots["totalMass"],
)
print(f"tilt needed at 20.0 m/s: {tilt_needed:5.2f} deg")

mass = drone.slots["totalMass"]
thrust = interp.call("DeepScout::PropThrust", kV=935.0, voltage=11.1, diameter=0.254)
usable = 4.0 * thrust * drone.slots["continuousThrustFraction"]
tilt = interp.call("DeepScout::CruiseTilt", massKg=mass, thrustN=usable, opsCapDeg=25.0)
speed = interp.call(
    "DeepScout::MaxCruiseSpeed", tiltDeg=tilt, massKg=mass, dragArea=drone.slots["dragArea"]
)
assert abs(speed - drone.slots["maxCruiseSpeed"]) < 1e-9
print(
    f"PropThrust {thrust:5.2f} N -> CruiseTilt {tilt:5.2f} deg -> MaxCruiseSpeed {speed:5.2f} m/s"
)

print(
    "HoverTime(capacity = 5200.0):", interp.call("DeepScout::HoverTime", capacity=5200.0), "minutes"
)
tilt needed at 20.0 m/s: 25.06 deg
PropThrust  8.32 N -> CruiseTilt 25.00 deg -> MaxCruiseSpeed 19.97 m/s
HoverTime(capacity = 5200.0): 23.28358208955224 minutes

Instances, constraints, what-if

instantiate builds a runtime instance from a definition. Attribute defaults evaluate in dependency order. Multiplicities expand, so the drone gets four motors and four propellers. check evaluates every assert constraint on the instance. The stock drone passes both of its constraints.

A keyword override at instantiation makes a what-if study one line. The cell below loads 0.6 kg of payload instead of the stock 0.2 kg. The heavy drone cruises faster, because cruise speed rises with mass under the same tilt cap. It also breaks the takeoff-mass constraint, and check reports the failure.

print(f"stock: {drone.slots['totalMass']:.2f} kg, {drone.slots['maxCruiseSpeed']:.1f} m/s")
for result in interp.check(drone):
    print(f"  [{'PASS' if result.passed else 'FAIL'}] {result.name}")

heavy = interp.instantiate("Rotorcraft::QuadCopter", payloadMass=0.6)
print(f"heavy: {heavy.slots['totalMass']:.2f} kg, {heavy.slots['maxCruiseSpeed']:.1f} m/s")
for result in interp.check(heavy):
    print(f"  [{'PASS' if result.passed else 'FAIL'}] {result.name}")
stock: 1.41 kg, 20.0 m/s
  [PASS] takeoffMassLimit
  [PASS] canHover
  [PASS] hoverCurrentBudget
  [PASS] cruiseCurrentBudget
  [PASS] escCurrentBudget
heavy: 1.81 kg, 22.6 m/s
  [FAIL] takeoffMassLimit
  [PASS] canHover
  [PASS] hoverCurrentBudget
  [PASS] cruiseCurrentBudget
  [PASS] escCurrentBudget

Requirement verdicts: assumptions gate

A requirement holds assume constraints and require constraints. If an assumption fails, the requirement does not apply, and the verdict is None instead of False. check_requirement reports both parts.

DeepScout::FlightEnvelope assumes a positive total mass and requires a thrust-to-weight ratio of at least 1.8. The cell below collects three verdicts:

  • The stock drone passes.

  • An overloaded drone with 1.2 kg of payload fails.

  • A corrupt record with a negative mass is not applicable.

def envelope(instance):
    result = interp.check_requirement("DeepScout::FlightEnvelope", subject=instance)
    return f"applicable={result.applicable!s:5s}  satisfied={result.satisfied}"


print("stock     :", envelope(drone))

overloaded = interp.instantiate("Rotorcraft::QuadCopter", payloadMass=1.2)
print("overloaded:", envelope(overloaded))

corrupt = interp.instantiate("Rotorcraft::QuadCopter")
corrupt.set("totalMass", -1.0)  # a bad telemetry record
print("corrupt   :", envelope(corrupt))
stock     : applicable=True   satisfied=True
overloaded: applicable=True   satisfied=False
corrupt   : applicable=False  satisfied=None

Actions run as graphs

An action body without successions runs in declaration order. DeepScout::PlanBattery is such a body. Its trace records each assignment and each branch decision.

If the body declares successions, the wiring decides the order, not the source text. The Survey action below lists its steps out of flight order. The first ... then ... successions wire them correctly, and the trace follows the wiring. The assert pins the executed order.

plan = interp.run_action("DeepScout::PlanBattery", inputs={"distanceKm": 12.0})
print("outputs:", plan.outputs)
for line in plan.trace:
    print("  ", line)

sortie = longeron.loads("""
package Sortie {
    action def Survey {
        out log : String;
        assign log := "";
        action land       { assign log := log + " land"; }
        action photograph { assign log := log + " photograph"; }
        action takeoff    { assign log := log + " takeoff"; }
        action transit    { assign log := log + " transit"; }
        first start then takeoff;
        first takeoff then transit;
        first transit then photograph;
        first photograph then land;
        first land then done;
    }
}
""")
flight = longeron.Interpreter(sortie).run_action("Sortie::Survey")
assert flight.outputs["log"] == " takeoff transit photograph land"
print("execution order:", flight.outputs["log"])
outputs: {'flightMinutes': 24.0, 'feasible': False}
   assign flightMinutes := 24.0
   if flightMinutes > HoverTime(capacity = 5200.0) -> True
   assign feasible := False
execution order:  takeoff transit photograph land

The flight state machine

DeepScout::FlightStates models the flight loop: idle, takingOff, flying, landing. The low_battery transition sends an RTL command and enters landing. The next cell draws the machine, interactively in JupyterLab. The cell after it simulates the same elements, so the diagram and the simulation cannot disagree.

from longeron import diagrams

diagrams.state_diagram(model.find("DeepScout::FlightStates"))
Diagram (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

sim = interp.simulate(
    "DeepScout::FlightStates", events=["launch", "airborne", "low_battery", "touchdown"]
)
for step in sim.trace:
    print(" ", step)
print(
    "final:", sim.final_state, "| launches:", sim.env["launches"], "| sent:", sim.sends[0].payload
)
  idle --launch--> takingOff
  takingOff --airborne--> flying
  flying --low_battery--> landing
  landing --touchdown--> idle
final: idle | launches: 1 | sent: RTL

Time triggers and a sequence trap

A number in the event list advances the simulation clock. An accept after transition measures time from state entry. The LinkWatchdog below tolerates a 60-second link outage, because the link returns in time. The second outage lasts 121 seconds, so the watchdog commands a return home.

Sequences can hide traps that no single event shows. DeepScout::SortieStates guards launch behind a 30 percent battery floor. The goAround transition re-enters airborne without that guard, and each re-entry burns 30 percent. Three go-arounds drive the battery attribute below zero. The SafeSortie requirement catches the breach when the final cell binds the simulated battery level. Tutorial 6, “Requirements: score, hunt, prove”, finds this class of defect automatically.

watchdog = longeron.loads("""
package Failsafe {
    state def LinkWatchdog {
        attribute rtlCommands : Integer := 0;
        entry; then linked;
        state linked;
        transition first linked accept lostLink then blind;
        state blind;
        transition first blind accept linkRestored then linked;
        transition first blind accept after 120.0
            do assign rtlCommands := rtlCommands + 1
            then returningHome;
        state returningHome;
    }
}
""")
sim = longeron.Interpreter(watchdog).simulate(
    "Failsafe::LinkWatchdog", events=["lostLink", 60.0, "linkRestored", "lostLink", 121.0]
)
for step in sim.trace:
    print(" ", step)
assert sim.final_state == "returningHome"
print("final:", sim.final_state, "at t =", sim.time, "s | RTL commands:", sim.env["rtlCommands"])
  linked --lostLink--> blind
  blind --linkRestored--> linked
  linked --lostLink--> blind
  blind --auto--> returningHome
final: returningHome at t = 181.0 s | RTL commands: 1
nominal = interp.simulate(
    "DeepScout::SortieStates", events=["launch", "land", "recharge", "launch", "land"]
)
trap = interp.simulate(
    "DeepScout::SortieStates", events=["launch", "goAround", "goAround", "goAround", "land"]
)
for name, sortie_sim in (("nominal", nominal), ("go-around x3", trap)):
    result = interp.check_requirement("DeepScout::SafeSortie", battery=sortie_sim.env["battery"])
    battery = sortie_sim.env["battery"]
    print(f"{name:13s} battery {battery:4d} %  SafeSortie satisfied={result.satisfied}")
nominal       battery   70 %  SafeSortie satisfied=True
go-around x3  battery  -20 %  SafeSortie satisfied=False

The answer

The model computed 20.0 m/s from its own mass, thrust, and drag parameters. No physics came from outside the model. In this notebook the Interpreter:

  • evaluated expressions with keyword bindings

  • called the drone’s calcs as functions

  • instantiated the drone and checked constraints, with what-if overrides

  • produced gated requirement verdicts

  • ran action graphs in succession order

  • simulated state machines with events and a clock

Where this goes next: tutorial 3, “Views for review”, lets reviewers read this model without reading text. Tutorial 4, “Trades: sizing the fleet”, applies the same executable physics to a whole fleet.