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






