3. Views for review

The question: a design review is tomorrow. How do reviewers read this model without reading text?

The subject is the DeepScout UAV program in examples/deepscout, the same workspace tutorial 1 walked as data and tutorial 2 executed. Reviewers do not read its six SysML files. They read views: an explorer tree, four diagram kinds, a property sheet, and saved view artifacts. Every view renders the same model object, so the views cannot disagree with the source.

You will learn how to:

  • open the explorer over the workspace and read relationships as tree rows.

  • render structure, state, action, and requirement views from one dispatcher.

  • follow the selection seam: tree, diagram, and inspector move together.

  • inspect values with first-class units, and edit with honest refusals.

  • save the reviewed model, and save diagrams back into it as view usages.

The first cell is the whole review surface: the tree beside the diagram, one click deep. (Widget cell: captured at landing.)

import longeron
from longeron.widgets import explore

program = longeron.load("../examples/deepscout")
ex = explore(program, layout="inline")
ex.select("Rotorcraft::QuadCopter")

assert ex.element is program.find("Rotorcraft::QuadCopter")
print("tree rows:", ex.tree.total_count)
print("selected:", ex.tree.selected[0], "->", ex.kind, "diagram beside the tree")
ex
tree rows: 1717
selected: Rotorcraft::QuadCopter -> structure diagram beside the tree
Explorer (static snapshot of the interactive widget)

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

The tree shows relationships, not just parts

The explorer pairs a tree with a diagram pane. One click selected QuadCopter. The diagram pane rendered its package and highlighted the selection. In JupyterLab, explorer.explore(program) docks the same panes as a main-area tab. layout="inline" keeps this notebook self-contained.

The tree lists relationships as rows under the element that owns them. The DeepScout program declares 23 satisfy rows, 10 connections, one allocation, and one dependency, plus its imports and one flow. A reviewer reads who satisfies what without opening a file. The tree toolbar’s link button hides the relationship rows. The next cell counts the rows, drives the toggle programmatically, and restores it.

from longeron import model as M

satisfies = [e for e in program.iter_tree() if isinstance(e, M.SatisfyUsage)]
print("satisfy rows:", len(satisfies))
print("one of them: satisfy", satisfies[0].subsets[0], "by", satisfies[0].by)

full = ex.tree.total_count
ex.tree.show_relationships = False
hidden = full - ex.tree.total_count
print(f"toggle off: {hidden} relationship rows leave the tree and the counts")
ex.tree.show_relationships = True

assert len(satisfies) == 23  # 13 rotorcraft edges, 9 tailless S&C edges, the tilt-tri's engine-out
assert hidden > 25  # satisfies, connections, allocations, imports, and more
assert ex.tree.total_count == full
satisfy rows: 23
one of them: satisfy PitchStability by FlyingWingSingle
toggle off: 90 relationship rows leave the tree and the counts

Four views, one dispatcher

diagrams.diagram(element) picks the view from the element’s kind:

View

Renders

structure

packages, definitions, usages, and their edges

state

hierarchical states, entry markers, labeled transitions

action

the succession graph the interpreter executes

requirements

requirements, satisfy edges, and the satisfying parts

The first three come from longeron.diagrams. The fourth is the explorer’s requirements kind, shown two sections below. The next cell dispatches three DeepScout elements and displays the flight state machine. (Widget cell: captured at landing.)

from longeron import diagrams

flight = diagrams.diagram(program.find("DeepScout::FlightStates"))
plan = diagrams.diagram(program.find("DeepScout::PlanBattery"))
kit = diagrams.diagram(program.find("ScoutParts::F450Kit"))

for widget in (flight, plan, kit):
    state = widget._lgn_view_state  # the stamp save_view reads later
    print(f"{state['element'].qualified_name:24s} -> {state['kind']} view")
flight
DeepScout::FlightStates  -> state view
DeepScout::PlanBattery   -> action view
ScoutParts::F450Kit      -> structure view
Diagram (static snapshot of the interactive widget)

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

The requirements view

A review needs the compliance landscape: which requirements exist, and who satisfies them. The explorer’s requirements kind projects that landscape from the selected element’s package. It draws the requirement boxes, the satisfy edges, and the satisfying parts. The projection is read-only. Every element keeps its real owner, so the view never mutates the model. (Widget cell: captured at landing.)

ex.select("Rotorcraft::QuadCopter")
ex.kind = "requirements"

satisfy_edges = [
    e for e in ex.diagram.source.value.edges if "sysml-edge-satisfies" in e.properties.cssClasses
]
print("satisfy edges drawn:", len(satisfy_edges))
print("one edge:", satisfy_edges[0].source.id, "->", satisfy_edges[0].target.id)
assert len(satisfy_edges) == 13  # the Rotorcraft package's own satisfy rows
ex
satisfy edges drawn: 13
one edge: Rotorcraft::QuadCopter -> DeepScout::FlightEnvelope
Explorer (static snapshot of the interactive widget)

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

The selection seam

This section is the canonical exposition of the seam. Tutorials 4 through 9 link here instead of re-teaching it.

Every longeron surface shares one selection contract:

  • Each surface exposes its selection as a widget trait.

  • Selecting in one surface writes the same qualified name into the others.

  • Each write settles at its first fixpoint, so no echo loops back.

  • Switching the diagram kind preserves the selection.

The next three cells prove each clause on the explorer. The tree drives the diagram. The diagram drives the tree with exactly one write in each direction. The kind switch keeps the selected element. Later tutorials attach more surfaces to the same contract: the trade plots in tutorial 4, the scoreboard in tutorial 6, and the 3D scene in tutorial 7.

ex.kind = "structure"
ex.select("Rotorcraft::QuadCopter")
assert tuple(ex.kind_switcher.options) == ("structure", "requirements")
assert tuple(ex.diagram.view.selection.ids) == ("Rotorcraft::QuadCopter",)

widget = ex.diagram
ex.select("Rotorcraft::TriCopter")
assert ex.diagram is widget  # same package scope: the cached widget is reused
print("tree -> diagram:", ex.tree.selected[0], "highlighted in the", ex.kind, "view")
tree -> diagram: Rotorcraft::TriCopter highlighted in the structure view
ex.select("DeepScout::MultiRotor")  # a known start, so this cell re-runs cleanly
widget = ex.diagram
tree_writes, diagram_writes = [], []
# bind each sink as a default arg: a re-run's observers keep writing to
# THEIR list, not to the rebound global
ex.tree.observe(lambda ch, sink=tree_writes: sink.append(ch["new"]), "selected")
widget.view.selection.observe(lambda ch, sink=diagram_writes: sink.append(ch["new"]), "ids")

widget.view.selection.ids = ["DeepScout::PlanBattery"]  # what a browser click does
assert ex.tree.selected == ["DeepScout::PlanBattery"]
assert ex.diagram is widget  # the clicked diagram is not rebuilt
assert tree_writes == [["DeepScout::PlanBattery"]]  # exactly one write each way
assert diagram_writes == [("DeepScout::PlanBattery",)]
print("diagram -> tree: one write each way, no echo")
diagram -> tree: one write each way, no echo
ex.select("DeepScout::FlightStates::idle")
assert tuple(ex.kind_switcher.options) == ("structure", "state", "requirements")

ex.kind = "state"  # switch the view, not the subject
assert ex.tree.selected == ["DeepScout::FlightStates::idle"]
assert tuple(ex.diagram.view.selection.ids) == ("DeepScout::FlightStates::idle",)
print("kind switch to 'state' kept the selection on 'idle'")
kind switch to 'state' kept the selection on 'idle'

The app: the no-code entry

longeron.widgets.open() builds the review workbench. In JupyterLab it docks a panel into the left sidebar. Reviewers without a notebook click the Longeron tile in Lab’s launcher instead. The tile starts one console session and runs the same app.open() call. Both surfaces are browser-only, so they stay prose here. (Sidebar and tile: captured at landing.)

The panel loads models through a path field with a Browse listing. A Connect-to-API fold loads from a Systems Modeling API server instead. Each model row carries an Explore button and a Score button. Explore docks the explorer you already used. Score docks the requirements scoreboard that tutorial 6 teaches.

Every button has a programmatic twin on the returned handle. The cells below drive the twins, so the whole workflow runs headless. The next cell opens the app, loads the workspace, and launches an explorer tab.

from longeron.widgets import app

application = app.open()
model = application.load_path("../examples/deepscout")
tab = application.explore_model(model)

print("layout strategy:", application.layout_strategy)
for entry in application.entries:
    print(f"loaded: {entry.source} (origin: {entry.origin})")
layout strategy: inline
loaded: ../examples/deepscout (origin: dir)

The inspector: units are first-class

app.open() builds an item inspector and exposes it as application.inspector. In JupyterLab it docks into the right sidebar. Click any element in an app-launched tab, and the sheet follows: a kind chip, a path breadcrumb, read-only property rows, and editable name, documentation, and value fields. (Sheet: captured at landing.)

Units get three dedicated facts on the sheet:

  • The value field shows the magnitude with its unit symbol: 1.5 kg.

  • The typed-by row keeps the type and names the unit beside it: Real [kg]. The type and the unit are different facts. Both stay visible.

  • The unit row gives the symbol and its dimension: kg mass, resolved from the model’s own unit table.

The next cell selects the airframe mass ceiling through the explorer tab and reads all three facts off the sheet.

tab.select("DeepScout::MultiRotor::maxTakeoffMass")
sheet = application.inspector
element = application.current_element
assert sheet.element is element  # the tab selection fed the seam

symbol, unit_row = sheet._unit_facts(element)  # the sheet's own formatter
print("value field:", sheet._value_field.value)
print("typed by:   ", f"{element.types[0]} [{symbol}]")
print("unit row:   ", unit_row)
assert sheet._value_field.value == "1.5 kg"
assert (element.types, symbol) == (["Real"], "kg")
assert unit_row == "kg \u2014 mass"  # em-dash: 'kg -- mass'
value field: 1.5 kg
typed by:    Real [kg]
unit row:    kg — mass

Relationship sheets navigate

Relationships get sheets too. Select a satisfy row, and the sheet shows the relationship chip, its endpoints, and the full declaration. The endpoint rows are clickable. Clicking one navigates the selection to that element, and the explorer tree reveals it. The next cell selects a satisfy row, then simulates the endpoint click with select_element, the seam’s write half.

quad_satisfy = next(
    e for e in model.iter_tree() if isinstance(e, M.SatisfyUsage) and e.by == "QuadCopter"
)
application.select_element(quad_satisfy)
assert application.current_element is quad_satisfy
print("declaration:", longeron.to_sysml(quad_satisfy).strip())

application.select_element(model.find("Rotorcraft::QuadCopter"))  # the endpoint click
assert tab.tree.selected == ["Rotorcraft::QuadCopter"]
print("endpoint click revealed:", tab.tree.selected[0])
declaration: satisfy FlightEnvelope by QuadCopter;
endpoint click revealed: Rotorcraft::QuadCopter

Editing cascades or refuses

Review findings become edits. The sheet’s fields commit through longeron.edit, and this notebook calls the same functions. Every edit validates before it mutates. A safe edit applies and cascades. An unsafe edit raises EditError and changes nothing. In the sheet, the refusal lands verbatim in an error strip, and the field reverts.

edit.rename rewrites every textual reference that reaches the element: typings, connector ends, satisfy targets, imports, and expression references. It then re-resolves every site to prove that nothing changed meaning. The next cell documents the quad, renames it, and watches the satisfy row follow. The cell after that attempts a rename that collides with a sibling name, and the model refuses it.

from longeron import edit

tracker = edit.track(model)
edit.set_doc(model, "Rotorcraft::QuadCopter", "Baseline quad-rotor configuration. Reviewed.")

renamed = edit.rename(model, "Rotorcraft::QuadCopter", "QuadRotor")
print("references rewritten:", tracker.changes[-1].detail["rewritten"])
assert model.find("Rotorcraft::QuadRotor") is renamed
assert quad_satisfy.by == "QuadRotor"  # the satisfy row followed the rename
print("the satisfy row now reads:", longeron.to_sysml(quad_satisfy).strip())
references rewritten: 4
the satisfy row now reads: satisfy FlightEnvelope by QuadRotor;
from longeron.errors import EditError

try:
    edit.rename(model, "Rotorcraft::QuadRotor", "TriCopter")  # a sibling owns that name
except EditError as refusal:
    print("refused:", refusal)
assert model.find("Rotorcraft::QuadRotor") is not None  # nothing moved

edit.rename(model, "Rotorcraft::QuadRotor", "QuadCopter")  # the review verdict: keep it
print("renamed back; the doc edit stays unsaved")
refused: name 'TriCopter' is already used by another member of Rotorcraft
renamed back; the doc edit stays unsaved

Value writes validate semantics

A reviewer types 0.42 [SI::kgg], and the write must not land. edit.set_attribute_value resolves every unit reference against the model’s unit table before anything mutates. An unknown symbol is refused with a did-you-mean hint. A resolvable unit with the wrong dimension is refused too, stating both dimensions. validate=False is the documented escape hatch for a deliberate re-dimensioning.

Validate-on-write is a review-integrity guarantee: a value that landed is a value that resolved. The next cell aims both bad writes at the mass ceiling and proves the value never moved.

mass_qname = "DeepScout::MultiRotor::maxTakeoffMass"
for attempt in ("0.42 [SI::kgg]", "0.42 [SI::s]"):
    try:
        edit.set_attribute_value(model, mass_qname, attempt)
    except EditError as refusal:
        print(f"{attempt!r} refused:\n    {refusal}")

assert model.find(mass_qname).value.expr.to_text() == "1.5 [SI::kg]"  # untouched
print("the mass ceiling still reads 1.5 kg")
'0.42 [SI::kgg]' refused:
    unit 'SI::kgg' does not resolve (did you mean 'SI::kg' or 'SI::g'?)
'0.42 [SI::s]' refused:
    current value of 'DeepScout::MultiRotor::maxTakeoffMass' is 'kg' [mass]; 's' [s] is duration; pass validate=False to override
the mass ceiling still reads 1.5 kg

Save the review copy

Every edit.* call records on the model’s change tracker. In the app, a dirty model row grows a dot, and the row’s tooltip lists the unsaved changes. Save clears the dot.

This workspace came from a directory, so Save needs an explicit path and writes one merged .sysml file. application.save_model(model, path=...) writes the file, marks the tracker saved, and refreshes the row. The next cell saves, reloads the file, and finds the review edit in the copy.

API-loaded models get Push instead of Save, which sends a commit through client.push_commit. This notebook runs no API server, so Push stays prose.

import tempfile
from pathlib import Path

assert tracker.dirty  # the doc edit is unsaved: the model row shows the dot
review_dir = Path(tempfile.mkdtemp())
saved = application.save_model(model, path=review_dir / "deepscout_review.sysml")

assert not tracker.dirty  # saved: the dot clears
reloaded = longeron.load(str(saved))
print("review note in the saved copy:", reloaded.find("Rotorcraft::QuadCopter").doc)
review note in the saved copy: Baseline quad-rotor configuration. Reviewed.

Saved views are review artifacts

A good diagram is worth keeping. save_view writes the current diagram into the model itself as a SysML v2 view usage. The usage is typed by a standard view definition and exposes the shown scope, so any SysML v2 tool can read the recipe. Presentation details land in a JSON sidecar beside the model sources: direction, routing, and the collapse state. restore_view rebuilds the widget from the view usage plus the sidecar. A missing sidecar degrades to default presentation, never to an error.

The explorer’s header has a save button, and ex.save_view is its programmatic twin. The cell below passes an explicit sidecar path, which keeps the shipped example directory clean. The restored widget then exports to SVG for the review packet.

from longeron import render, views

ex.select("DeepScout::FlightStates")
ex.kind = "state"
sidecar = review_dir / "views.json"
view = ex.save_view("flight review", sidecar=sidecar)
print("view usage:", view.qualified_name, "typed by", view.types[0])
assert program.find("DeepScout::flight review") is view

restored = views.restore_view(program, view, sidecar=sidecar)
svg_path = review_dir / "flight_review.svg"
render.to_svg(restored, svg_path)
print("review packet:", svg_path.name, "--", svg_path.stat().st_size, "bytes of SVG")
view usage: DeepScout::flight review typed by StandardViewDefinitions::StateTransitionView
review packet: flight_review.svg -- 11089 bytes of SVG

Where this goes

Reviewers read the model through views: the tree, four diagram kinds, the property sheet, and saved view artifacts. Every view rendered one model object. Every edit validated or refused. The model stayed the single source of truth, which is the review story.

Tutorial 4 attaches the trade-study surfaces to the same selection seam. Brushing a candidate in a plot selects it everywhere, exactly like the tree click in this notebook. Tutorial 6 attaches the scoreboard, and tutorial 7 attaches the 3D scene. The seam contract stays the one this notebook proved: one write per surface, no echo, and selection survives the view switch.