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