7. Geometry and the mission

The question: the model claims the camera sees the ground and the props clear the hull. Who measured?

The claims live in the installation requirement group of the DeepScout program. clearView demands an unobstructed camera view cone, and propClearance demands zero overlap between each propeller disc and the rest of the craft. Both state a measure and a limit, and neither carries a number. This notebook computes the numbers from the model’s own geometry, feeds them back as verdicts, and flies the result over a real globe.

You will learn how to:

  • render an M0 population as a to-scale 3D scene, cross-linked with the structure diagram;

  • render any configuration by selecting it, or any of its parts, in the linked views;

  • line up the whole MultiRotor build family, each craft baked from its own population;

  • measure camera occlusion and disc clearance per configuration, and read the engine='cad' contract;

  • paint a violating variant where it hurts;

  • inject measured values into requirement verdicts and the scoreboard;

  • fly the mission on a globe with the model’s own cruise attitude.

Prerequisites: tutorial 3 for the selection seam and tutorial 5 for M0 populations. The widgets need the viz extra. With the cad extra, the geometric checks are exact. Without it, they fall back to a deterministic mesh estimate.

import json
import math

import ipywidgets as W

import longeron
from longeron import diagrams, m0
from longeron.analysis import geometry, link, mission3d
from longeron.analysis.grand import ATLANTA_LOOP, drone_scene
from longeron.analysis.scoreboard import scoreboard
from longeron.widgets import mesh_viewer, mission_viewer

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

motors = model.find("Rotorcraft::QuadCopter::motors")
print(f"M1: {motors.qualified_name} : {motors.types[0]} [{motors.multiplicity.upper}]")

quad = m0.interpret(model, "Rotorcraft::QuadCopter")
motor_ids = [motor.id for motor in quad.root.slots["motors"]]
prop_ids = [prop.id for prop in quad.root.slots["propellers"]]
print("M0:", *motor_ids, sep="\n    ")
assert motors.multiplicity.upper.value == 4  # one usage, whatever the count
assert len(motor_ids) == 4  # four individuals, each with a stable id
M1: Rotorcraft::QuadCopter::motors : Motor [4]
M0:
    Rotorcraft::QuadCopter#0.motors#0
    Rotorcraft::QuadCopter#0.motors#1
    Rotorcraft::QuadCopter#0.motors#2
    Rotorcraft::QuadCopter#0.motors#3

The scene renders the population

At M1 the quad’s motors is one usage that says four exist. The M0 population holds four motor individuals with stable ids. Tutorial 5 teaches the M0 concept, so this notebook only uses it. grand.drone_scene interprets the QuadCopter at M0, sizes a to-scale mesh from the population’s own attribute values, and stamps every part with its individual id.

link.link_selection wires the structure diagram to the 3D scene through those ids. Tutorial 3 owns the selection seam, so this notebook only drives it. Click motors in the diagram, and all four motor cans pop in the scene. Click one can, and the diagram selects the one usage the individual derives from.

(Widget cell: captured at landing.)

HEIGHT = 650
mesh, part_map = drone_scene(model, "Rotorcraft::QuadCopter")
print(f"{len(mesh['parts'])} tagged parts; motor3 renders {part_map['motor3']}")

structure = diagrams.structure_diagram(model, height=f"{HEIGHT}px")
viewer = mesh_viewer(
    mesh,
    label="Rotorcraft::QuadCopter -- one M0 interpretation",
    width_px=HEIGHT,
    height_px=HEIGHT,
)
unlink = link.link_selection(structure, viewer, model)  # drone_scene already tagged the mesh

# a fixed 3D-pane width pins the responsive canvas; the diagram takes the rest
viewer.layout = W.Layout(width=f"{HEIGHT}px", flex="0 0 auto")
structure.layout.width = "auto"
structure.layout.flex = "1 1 auto"
W.HBox(
    [structure, viewer],
    layout=W.Layout(align_items="stretch", width="100%", overflow="hidden"),
)
13 tagged parts; motor3 renders Rotorcraft::QuadCopter#0.motors#2
HBox (static snapshot of the interactive widget)

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

# a click on the one M1 usage lights all four M0 individuals
structure.view.selection.ids = ["Rotorcraft::QuadCopter::motors"]
assert json.loads(viewer.highlight_json) == sorted(motor_ids)

# a definition reaches the same population through the usage it types
structure.view.selection.ids = ["ScoutParts::F450Kit::Propeller"]
assert json.loads(viewer.highlight_json) == sorted(prop_ids)

# a canvas pick of the third motor can projects back to the one usage
viewer.picked_json = json.dumps(["Rotorcraft::QuadCopter#0.motors#2"])
assert list(structure.view.selection.ids) == ["Rotorcraft::QuadCopter::motors"]
assert json.loads(viewer.highlight_json) == sorted(motor_ids)  # and fans back out
print("one M1 usage <-> four M0 individuals: the round trip holds headless")
one M1 usage <-> four M0 individuals: the round trip holds headless

Select a configuration, render that configuration

The DeepScout program is a build family, not one craft. The linked views follow one rule: selecting a configuration, or any of its parts, renders that configuration’s geometry in the 3D scene. One call installs the rule. link.bind_config_view resolves every selection through link.owning_config, bakes the owning configuration’s scene through grand.scene_for, swaps the viewer, and lights the selection. scene_for dispatches both DeepScout families. A build configuration bakes from its own M0 population, per-individual keys included. A fleet airframe shell like the TeardropQuad bakes from its own attributes. Its structural parts share the definition’s qualified name as their key. Its equipment does not: the rendered battery, flight controller, and camera are elements in the model, so each carries its own part-usage key. Pick the battery in the scene, and the diagram selects the shell’s battery usage, not just the craft. Selections with no renderable owner keep the current scene.

The binding subsumes the link_selection call above, so retire that link first. Select the tricopter’s tailMotor, and the tricopter renders with its tail motor lit. The quad never had a tail motor to show. Select the TeardropQuad shell, and the fleet airframe replaces the build family in the same pane.

(The widget above repaints: captured at landing.)

unlink()  # the one-scene link retires; the binding subsumes it
binding = link.bind_config_view(structure, viewer, model, showing="Rotorcraft::QuadCopter")

# drive the rule headless: select the tricopter's tail motor...
structure.view.selection.ids = ["Rotorcraft::TriCopter::tailMotor"]
assert binding.current == "Rotorcraft::TriCopter"
assert len(json.loads(viewer.mesh_json)["discs"]) == 3  # ...and THE TRICOPTER renders
assert json.loads(viewer.highlight_json) == ["Rotorcraft::TriCopter#0.tailMotor"]

# the same binding renders the FLEET shells: the teardrop's lathed body
structure.view.selection.ids = ["Rotorcraft::TeardropQuad"]
assert binding.current == "Rotorcraft::TeardropQuad"
shell = json.loads(viewer.mesh_json)
keys = {part["name"]: part["key"] for part in shell["parts"]}
# the structure keys to the craft; the equipment to its own elements
assert keys["frame"] == keys["motors"] == keys["bay"] == "Rotorcraft::TeardropQuad"
assert keys["battery"] == "Rotorcraft::TeardropQuad::battery"
assert keys["fc"] == "Rotorcraft::TeardropQuad::flightController"
assert keys["camera"] == "Rotorcraft::TeardropQuad::camera"

# ...so a canvas pick of the battery selects the BATTERY, not the craft
viewer.picked_json = json.dumps(["Rotorcraft::TeardropQuad::battery"])
assert list(structure.view.selection.ids) == ["Rotorcraft::TeardropQuad::battery"]
assert json.loads(viewer.highlight_json) == ["Rotorcraft::TeardropQuad::battery"]
print(f"tailMotor -> the tricopter; the {len(shell['parts'])}-part teardrop -> a clickable battery")
tailMotor -> the tricopter; the 7-part teardrop -> a clickable battery

The whole family, to scale

Five configurations specialize the abstract MultiRotor, and each declares its own rotor populations. The tricopter mounts two front motors plus one tail motor. The quad and the hexa each declare one motors population. The X8 stacks four coaxial pairs, and the flat octo spreads eight motors on a ring. drone_scene reads whichever population shape a configuration declares, so one call per configuration bakes the family. geometry.lineup merges the scenes onto one ground plane at true scale.

(Widget cell: captured at landing.)

FAMILY = ("TriCopter", "QuadCopter", "HexaCopter", "CoaxX8", "OctoCopter")
family = {name: drone_scene(model, f"Rotorcraft::{name}") for name in FAMILY}
for name in FAMILY:
    config_mesh, _keys = family[name]
    print(f"{name:12s} {len(config_mesh['discs'])} rotor discs, baked from its own population")
assert [len(family[name][0]["discs"]) for name in FAMILY] == [3, 4, 6, 8, 8]

parade = geometry.lineup([family[name][0] for name in FAMILY], labels=list(FAMILY))
mesh_viewer(parade, label="the MultiRotor build family, to scale", height_px=420)
TriCopter    3 rotor discs, baked from its own population
QuadCopter   4 rotor discs, baked from its own population
HexaCopter   6 rotor discs, baked from its own population
CoaxX8       8 rotor discs, baked from its own population
OctoCopter   8 rotor discs, baked from its own population
longeron.widgets.viewer3d._viewer_class (static snapshot of the interactive widget)

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

Geometric requirements: the model asks, the kernel measures

The installation requirement group states both geometric claims with limits attached. clearView requires occludedFraction <= 0.0: the camera’s view cone shall contain no part of the craft’s own airframe. propClearance requires discOverlapVolume <= 0.0: no propeller disc shall overlap any other component. The model declares each measure as an unvalued attribute, together with a weight, a utility shape, and a unit. It never claims a value for them.

longeron.analysis.geometry computes the values. occlusion_report builds a view-cone solid at the camera and intersects it with every other component. overlap_report intersects each propeller disc with the rest of the assembly. Both consume the same parametric geometry the 3D scene paints, so the measure and the picture cannot drift apart.

family_checks = {}
print(f"{'config':13s}{'engine':>7s}{'occludedFraction':>18s}{'discOverlap m^3':>17s}")
for name in FAMILY:
    config_mesh = family[name][0]
    report = geometry.occlusion_report(config_mesh)  # engine='auto': CAD when installed
    overlap = geometry.disc_overlap(config_mesh)
    family_checks[name] = {
        "occludedFraction": report["occludedFraction"],
        "discOverlapVolume": overlap,
    }
    print(f"{name:13s}{report['engine']:>7s}{report['occludedFraction']:>18.6f}{overlap:>17.6f}")

# every stock build keeps its discs clear of everything, in either engine
assert all(checks["discOverlapVolume"] == 0.0 for checks in family_checks.values())
# the tricopter and the quad also keep their view cones exactly clear
assert family_checks["TriCopter"]["occludedFraction"] == 0.0
assert family_checks["QuadCopter"]["occludedFraction"] == 0.0
config        engine  occludedFraction  discOverlap m^3
TriCopter        cad          0.000000         0.000000
QuadCopter       cad          0.000000         0.000000
HexaCopter       cad          0.000040         0.000000
CoaxX8           cad          0.000210         0.000000
OctoCopter       cad          0.000095         0.000000

The X8 finding

The coax X8 packs eight rotors onto the quad’s frame size, and the packing has a price. Its two lower forward discs graze the belly camera’s view cone. The CAD engine reads occludedFraction at 0.000210 and names the offending discs with their intersected volumes. The part map turns the part names back into M0 individual ids, so the finding points at two specific propeller individuals.

A designer fixes the mount, not the requirement. Hang the camera pod below the lower disc plane, and the same check re-measures the fix to exactly zero. The what-if needs no model edit, because camera_occlusion takes an explicit camera mapping.

x8_mesh, x8_map = family["CoaxX8"]
report = geometry.occlusion_report(x8_mesh)
print(f"engine {report['engine']}: occludedFraction {report['occludedFraction']:.6f}")
for part, volume in report["obstructions"].items():
    print(f"  {part} = {x8_map[part]}: {volume * 1e6:.1f} cm^3 inside the view cone")
if report["engine"] == "cad":
    assert report["occludedFraction"] > 0.0  # the graze is real
    assert set(report["obstructions"]) == {"prop5", "prop6"}  # the forward pair's lower discs
else:
    assert report["occludedFraction"] == 0.0  # the sliver hides below the mesh grid

# the designer's move: hang the camera below the lower disc plane, re-measure
camera = x8_mesh["camera"]
fixed = geometry.camera_occlusion(x8_mesh, camera={**camera, "y": -0.08})
print(f"camera pod dropped to y = -0.08 m: occludedFraction {fixed:.6f}")
assert fixed == 0.0  # the same check verifies the fix, in either engine
engine cad: occludedFraction 0.000210
  prop6 = Rotorcraft::CoaxX8#0.propellers#5: 10.1 cm^3 inside the view cone
  prop5 = Rotorcraft::CoaxX8#0.propellers#4: 10.1 cm^3 inside the view cone
camera pod dropped to y = -0.08 m: occludedFraction 0.000000

The engine contract

engine='cad' rebuilds every part’s parametric solid and intersects exact booleans in the OCC kernel, so a 10 cm^3 sliver is a 10 cm^3 answer. engine='mesh' integrates the same measure by a deterministic volume quadrature over the mesh triangles, with no extra dependency. The quadrature counts only genuine interior points, so its nonzero readings are real. It can miss features thinner than a grid cell.

Read a mesh zero as “nothing grid-cell-sized”, and read a CAD zero as zero. Every report names the engine that produced it. When the cad extra is installed, the default engine='auto' prefers CAD. The X8 graze sits below the mesh grid, so it separates the two engines.

quadrature = geometry.occlusion_report(x8_mesh, engine="mesh")
preferred = geometry.occlusion_report(x8_mesh, engine="auto")
print(f"mesh quadrature: occludedFraction {quadrature['occludedFraction']:.6f}")
print(f"auto -> {preferred['engine']}: occludedFraction {preferred['occludedFraction']:.6f}")
assert quadrature["engine"] == "mesh"
assert quadrature["occludedFraction"] == 0.0  # nothing grid-cell-sized
if preferred["engine"] == "cad":
    assert preferred["occludedFraction"] > 0.0  # the exact booleans see the graze
mesh quadrature: occludedFraction 0.000000
auto -> cad: occludedFraction 0.000210

A violating variant, painted where it hurts

propClearance held on every stock build, so build a variant that fails. A frame does not grow when a bigger prop is bolted on. The prop swap below keeps the quad’s stock motor spacing and mounts 12-inch props in place of the 10-inch catalog props. Neighbouring discs now cut into each other, and disc_overlap reports the lens-shaped volumes. The scene lights every colliding disc, so the violation sits exactly where a mechanic would look.

(Widget cell: captured at landing.)

stock_spacing = interp.evaluate("ScoutParts::F450Kit::Propeller::diameter") + 0.02
oversized = geometry.drone_geometry(
    prop_diameter_in=12.0,
    motor_mass=interp.evaluate("ScoutParts::F450Kit::Motor::mass"),
    battery_mass=interp.evaluate("ScoutParts::F450Kit::Battery::mass"),
    esc_mass=0.012,  # the 30.5 mm stack heuristic; the model carries no ESC part
    split_instances=True,
    camera=dict(quad.root.slots["camera"].slots),
    motor_spacing=stock_spacing,  # the frame the 10-inch props were sized for
)
rows = geometry.overlap_report(oversized)
for entry in rows:
    into = ", ".join(entry["parts"]) or "nothing"
    print(f"{entry['disc']}: {entry['overlap'] * 1e6:.1f} cm^3 of overlap, into {into}")
total = sum(entry["overlap"] for entry in rows)
print(f"discOverlapVolume = {total * 1e6:.1f} cm^3 in total")
assert total > 0.0  # propClearance violated, and both engines agree it is nonzero

violated = mesh_viewer(oversized, label="12-inch props on the stock spacing -- the discs collide")
violated.highlight([entry["disc"] for entry in rows if entry["overlap"] > 0])
violated
prop1: 13.9 cm^3 of overlap, into prop2, prop3
prop2: 13.9 cm^3 of overlap, into prop1, prop4
prop3: 13.9 cm^3 of overlap, into prop4, prop1
prop4: 13.9 cm^3 of overlap, into prop3, prop2
discOverlapVolume = 55.4 cm^3 in total
longeron.widgets.viewer3d._viewer_class (static snapshot of the interactive widget)

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

Measured values become verdicts and a score

The model asked, the kernel measured, and one argument joins them. check_requirement binds a measured value to the requirement’s free attribute and evaluates the require constraint to a verdict. scoreboard(model, values=...) injects the same numbers into the model’s whole requirement hierarchy and scores it. Tutorial 6 owns the scoreboard’s full story. Nothing is written into the model file. The missionTime row stays unmeasured until the next section flies the route.

(Widget cell: captured at landing.)

quad_checks = geometry.geometry_checks(family["QuadCopter"][0])
for requirement, attribute in (
    ("DeepScout::installation::clearView", "occludedFraction"),
    ("DeepScout::installation::propClearance", "discOverlapVolume"),
):
    verdict = interp.check_requirement(requirement, **{attribute: quad_checks[attribute]})
    print(f"{requirement}: measured {quad_checks[attribute]:.6f} -> {verdict.satisfied}")
    assert verdict.satisfied

# the X8's measured graze, judged by the same requirement
x8_fraction = family_checks["CoaxX8"]["occludedFraction"]
x8_verdict = interp.check_requirement(
    "DeepScout::installation::clearView", occludedFraction=x8_fraction
)
print(f"clearView on the X8 ({x8_fraction:.6f}): {x8_verdict.satisfied}")
assert x8_verdict.satisfied == (x8_fraction <= 0.0)  # the verdict follows the measure

board = scoreboard(model, values=quad_checks)
print(board)
utilities = {row.name: row.utility for row in board.table()}
assert utilities["clearView"] == 1.0
assert utilities["propClearance"] == 1.0
assert math.isnan(utilities["missionTime"])  # unmeasured until the route is flown
board.widget()
DeepScout::installation::clearView: measured 0.000000 -> True
DeepScout::installation::propClearance: measured 0.000000 -> True
clearView on the X8 (0.000210): False
requirement                                  weight  share          raw utility aggregate
requirements                                      1  100%            -       -     90.6%
  installation                                    1   33%            -       -    100.0%
    clearView                                     2   67%   0 fraction  100.0%    100.0%
    propClearance                                 1   33%        0 m^3  100.0%    100.0%
  mission                                         1   33%            -       -         -
    missionTime                                 1.5  100%            -       -         -
  scoring                                         1   33%            -       -     81.1%
    effectiveness                                 3   43%            -       -     96.7%
      isrStation                                  2   40%      275 min  100.0%    100.0%
      logisticsWork                               2   40%    187 kg km  100.0%    100.0%
      interceptCatch                              1   20%     66.8 m/s   83.6%     83.6%
    affordability                                 2   29%            -       -     62.9%
      fleetPrice                                  1  100% 9.71e+03 USD   62.9%     62.9%
    operability                                   2   29%            -       -     75.9%
      crewPortable                                2   50%      6.89 kg   63.9%     63.9%
      regulatory                                  1   25%         pass  100.0%    100.0%
      autonomyRoadmap                             1   25%            -       -         -
score (saw)                                                                         90.6%
longeron.analysis.scoreboard._widget_class (static snapshot of the interactive widget)

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

The mission on the globe

Geometry also moves. mission3d.from_replay executes the model’s FlightStates machine, maps the recorded execution onto a waypoint route, and flies the same to-scale mesh over a CesiumJS globe. The route is the Atlanta loop that tutorial 4’s family matrix priced. The cruise attitude comes from the model’s cruiseTilt calc, so the quad rides at its 25-degree operational cap. The cruise leg’s clock spends what the physics prices: routeM over maxCruiseSpeed.

mission_values turns the same route into scoreboard numbers. The quad finishes the sortie in 4.24 minutes against the model’s 6-minute budget, so missionTime finally gets its value and its verdict.

(Widget cell: captured at landing. Offline front-ends print a notice instead of the globe.)

quad_vals = mission3d.mission_values(interp, ATLANTA_LOOP, ground_alt=300.0)
cruise_s = quad_vals["routeM"] / quad_vals["cruiseSpeedMps"]  # the clock the physics prices
tilt = mission3d.model_tilt(interp, "Rotorcraft::QuadCopter")
track = mission3d.from_replay(
    interp,
    "DeepScout::FlightStates",
    [2.0, "launch", 6.0, "airborne", cruise_s, "low_battery", 10.0, "touchdown"],
    waypoints=ATLANTA_LOOP,
    ground_alt=300.0,  # midtown Atlanta sits ~300 m MSL
    tilt_deg=tilt,
)
print(
    f"route {quad_vals['routeM'] / 1000:.1f} km at {quad_vals['cruiseSpeedMps']:.1f} m/s, "
    f"{tilt:.0f} deg tilt -> {quad_vals['missionMinutes']:.2f} min against the 6.0 min budget"
)
assert quad_vals["missionMinutes"] < 6.0

board = scoreboard(model, values={**quad_checks, **quad_vals})
mission_row = {row.name: row for row in board.table()}["missionTime"]
print(f"missionTime utility: {mission_row.utility:.2f}")
assert mission_row.utility > 0.8
mission_viewer(track, mesh=mesh, height_px=420)
route 3.9 km at 20.0 m/s, 25 deg tilt -> 4.24 min against the 6.0 min budget
missionTime utility: 0.88
longeron.widgets.mission3d._viewer_class (static snapshot of the interactive widget)

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

One clock scrubs every view

The globe above and tutorial 2’s replay player each replay this mission, and each keeps a private playhead. The time seam links them. record_timeline records the flight once, and track_from_timeline maps that same recording onto the route, so both views play one truth. A Clock holds the shared playhead, and link_time subscribes the player, the globe, and a scrubber bar to it. A seek in any view moves all of them. Press play on the scrubber or on the Cesium dial, and both advance together.

(Widget cell: three linked views. Offline front-ends print a notice instead of the globe.)

# one recording feeds every view; a Clock links their playheads
from longeron import replay
from longeron.widgets import Clock, Timebase, link_time, replay_widget, time_scrubber

timeline = replay.record_timeline(
    interp,
    "DeepScout::FlightStates",
    [2.0, "launch", 6.0, "airborne", cruise_s, "low_battery", 10.0, "touchdown"],
)
seam_track = mission3d.track_from_timeline(
    timeline, waypoints=ATLANTA_LOOP, ground_alt=300.0, tilt_deg=tilt
)
player = replay_widget(interp, "DeepScout::FlightStates", timeline=timeline, width_px=560)
globe = mission_viewer(seam_track, mesh=mesh, height_px=380)
timebase = Timebase(timeline, track=seam_track)
clock = Clock(span=timebase.span)
scrubber = time_scrubber(timebase)
unlink_time = link_time(clock, player, scrubber, globe)
clock.seek(timebase.span[1] / 2)  # one seek moves every playhead
assert abs(player.time - clock.t) < 1e-3 and abs(globe.time - clock.t) < 1e-3
print(f"scrubbed to {clock.t:.1f} s / phase: {timebase.phase_at(clock.t)[0]}")
W.VBox([player, scrubber, globe])
scrubbed to 107.0 s / phase: route
VBox (static snapshot of the interactive widget)

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

The same route, on the tricopter

Tutorial 4’s family matrix already showed the tricopter busting the mission budget, as one number in a table. Here the same model fact becomes a slower flight. The tricopter’s cruiseTilt ceiling is 11 degrees, so its cruise speed drops to 12.4 m/s. The identical loop then takes 6.22 minutes, and the 6-minute budget busts. The scoreboard turns the bust into a zero-utility missionTime row, and the globe flies the tricopter’s own three-arm geometry through the longer sortie.

(Widget cell: captured at landing.)

tri_vals = mission3d.mission_values(
    interp, ATLANTA_LOOP, ground_alt=300.0, assembly="Rotorcraft::TriCopter"
)
print(
    f"TriCopter: {tri_vals['cruiseTiltDeg']:.0f} deg tilt, {tri_vals['cruiseSpeedMps']:.1f} m/s "
    f"-> {tri_vals['missionMinutes']:.2f} min: the 6.0 min budget busts"
)
assert tri_vals["missionMinutes"] > 6.0

tri_board = scoreboard(model, values={**family_checks["TriCopter"], **tri_vals})
tri_row = {row.name: row for row in tri_board.table()}["missionTime"]
print(f"missionTime utility on the tricopter: {tri_row.utility:.1f}")
assert tri_row.utility == 0.0  # the bust reads as zero utility

tri_cruise_s = tri_vals["routeM"] / tri_vals["cruiseSpeedMps"]
tri_track = mission3d.from_replay(
    interp,
    "DeepScout::FlightStates",
    [2.0, "launch", 6.0, "airborne", tri_cruise_s, "low_battery", 10.0, "touchdown"],
    waypoints=ATLANTA_LOOP,
    ground_alt=300.0,
    tilt_deg=tri_vals["cruiseTiltDeg"],
)
print(f"cruise leg: {tri_cruise_s:.0f} s on the tricopter vs {cruise_s:.0f} s on the quad")
assert tri_track.duration > track.duration  # the same loop, visibly slower
mission_viewer(tri_track, mesh=family["TriCopter"][0], height_px=420)
TriCopter: 11 deg tilt, 12.4 m/s -> 6.22 min: the 6.0 min budget busts
missionTime utility on the tricopter: 0.0
cruise leg: 315 s on the tricopter vs 196 s on the quad
longeron.widgets.mission3d._viewer_class (static snapshot of the interactive widget)

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

Geometry is a view of the same truth

Nothing in this notebook lived outside the model. scene_for baked every scene from the model itself: the build family from its M0 population, the fleet shell from its own attribute values. The geometry checks measured the model’s own attribute values. check_requirement turned the measures into verdicts, and FlightStates flew the route at the model’s own physics. The 3D pane, the scoreboard, and the globe render one source of truth. Change the model, and every view changes.

Tutorial 8 queries the same truth as a knowledge graph: SPARQL over the model’s RDF projection, with answers you can verify against these views. Tutorial 9 assembles every seam from tutorials 3 through 8 into one grand-tour surface.