Skip to content

Results session

The document behind post-solve presentation: ResultsSession — docked mesh and plot panes, one time link, one selection. Results answers what did the solver write?; the session answers what is on screen, at which time, with which pictures, with which pick.

Since ADR 0098 this is what results.viewer() opens. The window (Viewers) is one client that projects the document; render() is another that draws a still with no Qt at all, and the snapshot is the document written to disk. VTK actors, docks and widgets are never truth — which is why a script can build, read and render exactly what a human arranged in the window.

from apeGmsh import Results
from apeGmsh.results.session import Contour, Deform, Instant

results = Results.demo()

s = results.session()                 # the document — one empty mesh view
view = s.panes[0]
view.contour = Contour("displacement_x")
view.deform = Deform("displacement", scale=5.0)
s.time = Instant("stage_0", 5)

s.render("tip.png")                   # a still, no Qt

results.viewer() is sugar for results.session() + show(), and returns the session once the window closes — still live, so you can query it, render more stills off it, or snapshot it.

The session — results.session()

results.session() boots the default picture: one empty mesh view (grey analysis mesh, mesh + outlines on, no slots, no legends, unscoped, undeformed) bound to that broker. Section cuts persisted under /opensees/cuts/ come back on that view as clips; a cut that cannot be translated honestly into a plane is skipped with one [session] line rather than silently widening what disappears.

A session with no results= is valid IR — the snapshot round-trips it — but realize() and render() refuse loudly, because there is nothing to read numbers from.

apeGmsh.results.session.ResultsSession

ResultsSession(results: 'Optional[Results]' = None)

The presentation library's root object.

Constructing one gives you presentation with no window (§1); render() / realize() (S1) and show() (S2) are clients. A session of zero panes is valid IR — the results.session() factory owns the default-one-empty-mesh-view sugar (and the results= binding the realize clients need).

Source code in src/apeGmsh/results/session/_session.py
def __init__(self, results: "Optional[Results]" = None) -> None:
    self._results = results
    self._panes: list[Pane] = []
    self._ids = itertools.count(1)
    self._time: Optional[Instant] = None
    self._time_linked: bool = True
    self._subscribers: list[Callable[[], None]] = []
    self._selection = SessionSelection(on_changed=self._tick)

results property

results: 'Optional[Results]'

The data broker this session presents (§1: Results answers "what did the solver write?"; the session answers what is on screen). None for a bare IR-only session — realize and render then refuse loudly.

panes property

panes: tuple[Pane, ...]

Every pane, in creation order (the outline's panes group).

time property writable

time: Optional[Instant]

The session instant. While the link is on this is THE instant of every pane (§7).

time_linked property writable

time_linked: bool

Linked: one instant — scrubber, every mesh view, every plot cursor. Unlinked: each pane keeps its own. If the link is on and moving the plot does not move the meshes, the link is a lie (§7).

selection property

selection: SessionSelection

The one selection set — nodes XOR Gauss, last writer wins. The owner-facing surface of the one SelectionState (0045 INV-5), never a second store.

pane

pane(pane_id: str) -> Pane

The pane with this stable id. A miss is a KeyError — the id is how render / MCP / snapshot address a pane, so a stale id must fail loudly, never guess.

Source code in src/apeGmsh/results/session/_session.py
def pane(self, pane_id: str) -> Pane:
    """The pane with this stable id. A miss is a ``KeyError`` — the
    id is how render / MCP / snapshot address a pane, so a stale id
    must fail loudly, never guess."""
    for pane in self._panes:
        if pane.id == pane_id:
            return pane
    raise KeyError(
        f"No pane {pane_id!r} in this session (have: "
        f"{[p.id for p in self._panes]})."
    )

add_view

add_view(name: Optional[str] = None) -> MeshView

A new mesh view, booted to the valid empty picture (§3).

Source code in src/apeGmsh/results/session/_session.py
def add_view(self, name: Optional[str] = None) -> MeshView:
    """A new mesh view, booted to the valid empty picture (§3)."""
    view = MeshView(
        pane_id=f"mesh-{next(self._ids)}",
        name=name,
        on_changed=self._tick,
    )
    self._panes.append(view)
    self._tick()
    return view

add_plot

add_plot(kind: str = 'history', series: Sequence[PlotSeries] = (), name: Optional[str] = None) -> PlotView

A new plot pane (§6).

Source code in src/apeGmsh/results/session/_session.py
def add_plot(
    self,
    kind: str = "history",
    series: Sequence[PlotSeries] = (),
    name: Optional[str] = None,
) -> PlotView:
    """A new plot pane (§6)."""
    plot = PlotView(
        pane_id=f"plot-{next(self._ids)}",
        kind=kind,
        series=series,
        name=name,
        on_changed=self._tick,
    )
    self._panes.append(plot)
    self._tick()
    return plot

add_plot_from_selection

add_plot_from_selection(quantity: str, kind: str = 'history', name: Optional[str] = None) -> PlotView

The "select → New plot" law (§6/§8): the selection set IS the source. Membership is COPIED into concrete node/gauss sources at creation — mutating the selection afterwards does not touch the plot (a live alias is not v1). An empty selection has nothing to plot and refuses loudly.

Source code in src/apeGmsh/results/session/_session.py
def add_plot_from_selection(
    self,
    quantity: str,
    kind: str = "history",
    name: Optional[str] = None,
) -> PlotView:
    """The "select → New plot" law (§6/§8): the selection set IS
    the source. Membership is COPIED into concrete node/gauss
    sources at creation — mutating the selection afterwards does
    not touch the plot (a live alias is not v1). An empty selection
    has nothing to plot and refuses loudly."""
    sel = self._selection
    if sel.kind == "nodes":
        sources = [PlotSource.node(i) for i in sel.nodes]
    elif sel.kind == "gauss":
        sources = [PlotSource.gauss(e, gp) for e, gp in sel.gauss]
    else:
        raise ValueError(
            "Selection is empty — select nodes or Gauss points "
            "before creating a plot from the selection."
        )
    series = tuple(
        PlotSeries(source=s, quantity=quantity) for s in sources
    )
    return self.add_plot(kind=kind, series=series, name=name)

effective_instant

effective_instant(pane: 'Pane | str') -> Optional[Instant]

THE instant this pane is at — the §7 law in one place.

  • A mode-posed mesh view has no instant, linked or not: the scrubber moves instants and a mode has none (§4/§7).
  • Link on → the session instant, for every other pane; pane time is ignored (§9 — set it here; the link ignores it).
  • Link off → the pane's own time / cursor.
Source code in src/apeGmsh/results/session/_session.py
def effective_instant(self, pane: "Pane | str") -> Optional[Instant]:
    """THE instant this pane is at — the §7 law in one place.

    * A mode-posed mesh view has no instant, linked or not: the
      scrubber moves instants and a mode has none (§4/§7).
    * Link on → the session instant, for every other pane; pane
      time is ignored (§9 — set it here; the link ignores it).
    * Link off → the pane's own ``time`` / ``cursor``.
    """
    if isinstance(pane, str):
        pane = self.pane(pane)
    if isinstance(pane, MeshView):
        if pane.is_mode_posed:
            return None
        return self._time if self._time_linked else pane.time
    return self._time if self._time_linked else pane.cursor

realize

realize(backend: Any = None, pane: 'Pane | str | None' = None)

Realize one pane (S1).

A mesh pane is one-shot: it emits its complete layer set into backend (an ADR 0042 RenderBackend) and returns a RealizedPane whose layers carry stable keys — the S2 reconciler's diff surface. A plot pane resolves its series to arrays and returns a RealizedPlot; it needs no backend (§6: its client draws the numbers). With one pane no id is needed; otherwise address by pane id.

The projection lives in apeGmsh.viewers.session (imported lazily here) — the session package itself stays free of the diagram/Qt/VTK machinery per the S0 purity guard.

Source code in src/apeGmsh/results/session/_session.py
def realize(
    self, backend: Any = None, pane: "Pane | str | None" = None,
):
    """Realize one pane (S1).

    A **mesh** pane is one-shot: it emits its complete layer set
    into ``backend`` (an ADR 0042 ``RenderBackend``) and returns a
    ``RealizedPane`` whose layers carry stable keys — the S2
    reconciler's diff surface. A **plot** pane resolves its series
    to arrays and returns a ``RealizedPlot``; it needs no backend
    (§6: its client draws the numbers). With one pane no id is
    needed; otherwise address by pane id.

    The projection lives in ``apeGmsh.viewers.session`` (imported
    lazily here) — the session package itself stays free of the
    diagram/Qt/VTK machinery per the S0 purity guard.
    """
    from apeGmsh.viewers.session import realize_pane, resolve_pane

    return realize_pane(self, resolve_pane(self, pane), backend)

render

render(path: 'str | Path', pane: 'Pane | str | None' = None, *, camera: Optional[str] = None, window_size: tuple[int, int] = (1280, 720)) -> Optional[Path]

Write one offscreen still of a pane (§1). Path or None.

Same skip discipline as results.render (ADR 0094): APEGMSH_SKIP_VIEWER=1 or no GL context prints the [skip viewer] notice, writes no file and returns None. camera= defaults to xy for a planar model, iso otherwise.

Source code in src/apeGmsh/results/session/_session.py
def render(
    self,
    path: "str | Path",
    pane: "Pane | str | None" = None,
    *,
    camera: Optional[str] = None,
    window_size: tuple[int, int] = (1280, 720),
) -> Optional[Path]:
    """Write one offscreen still of a pane (§1). Path or ``None``.

    Same skip discipline as ``results.render`` (ADR 0094):
    ``APEGMSH_SKIP_VIEWER=1`` or no GL context prints the
    ``[skip viewer]`` notice, writes no file and returns ``None``.
    ``camera=`` defaults to ``xy`` for a planar model, ``iso``
    otherwise.
    """
    from apeGmsh.viewers.session import render_still

    return render_still(
        self, pane, path, camera=camera, window_size=window_size,
    )

show

show(*, blocking: bool = True, title: 'Optional[str]' = None)

Open the Qt client on this session (ADR 0098 §1, S2).

The window is a projection: the outline lists the panes, the inspector edits the selected one through the §9 Add / change / clear loop, and the viewport reconciles the selected mesh view — every gesture writes THIS session, same as a script would. N tiled panes arrive with S3's pane host; until then one mesh view shows at a time.

blocking=False presents the window on an already-running Qt loop and returns immediately. APEGMSH_SKIP_VIEWER=1 prints the standard skip notice and returns None. Returns the SessionWindow handle otherwise.

The Qt client lives in apeGmsh.viewers.session (imported lazily here) — the session package itself stays free of the Qt/VTK machinery per the S0 purity guard.

Source code in src/apeGmsh/results/session/_session.py
def show(
    self, *, blocking: bool = True, title: "Optional[str]" = None,
):
    """Open the Qt client on this session (ADR 0098 §1, S2).

    The window is a projection: the outline lists the panes, the
    inspector edits the selected one through the §9 Add / change /
    clear loop, and the viewport reconciles the selected mesh view
    — every gesture writes THIS session, same as a script would.
    N tiled panes arrive with S3's pane host; until then one mesh
    view shows at a time.

    ``blocking=False`` presents the window on an already-running
    Qt loop and returns immediately. ``APEGMSH_SKIP_VIEWER=1``
    prints the standard skip notice and returns ``None``. Returns
    the ``SessionWindow`` handle otherwise.

    The Qt client lives in ``apeGmsh.viewers.session`` (imported
    lazily here) — the session package itself stays free of the
    Qt/VTK machinery per the S0 purity guard.
    """
    from apeGmsh.viewers.session import show_session

    return show_session(self, blocking=blocking, title=title)

snapshot

snapshot() -> dict

This session as a JSON-safe dict (ADR 0098 §11 S5).

Panes, slots, pose, the time link AND every pane's own instant, the one selection set. Nothing derived (legends are a function of the slots, §5) and nothing about a window: the snapshot is the document, so an agent can draw a still of what a human arranged without Qt.

Source code in src/apeGmsh/results/session/_session.py
def snapshot(self) -> dict:
    """This session as a JSON-safe dict (ADR 0098 §11 S5).

    Panes, slots, pose, the time link AND every pane's own instant,
    the one selection set. Nothing derived (legends are a function
    of the slots, §5) and nothing about a window: the snapshot is
    the document, so an agent can draw a still of what a human
    arranged without Qt.
    """
    from ._snapshot import snapshot

    return snapshot(self)

save_snapshot

save_snapshot(path: 'str | Path | None' = None) -> Path

Write :meth:snapshot atomically; returns the path written.

path=None defaults to <results>.viewer-session.json beside the results file — the old viewer's name, adopted at the S6a flip now that nothing else writes it (plan decision 11). A v13 file already sitting there is renamed aside, never overwritten; :func:~apeGmsh.results.session.rename_legacy_aside is the guarantee.

Source code in src/apeGmsh/results/session/_session.py
def save_snapshot(self, path: "str | Path | None" = None) -> Path:
    """Write :meth:`snapshot` atomically; returns the path written.

    ``path=None`` defaults to ``<results>.viewer-session.json``
    beside the results file — the old viewer's name, adopted at the
    S6a flip now that nothing else writes it (plan decision 11).
    A v13 file already sitting there is renamed aside, never
    overwritten; :func:`~apeGmsh.results.session.rename_legacy_aside`
    is the guarantee.
    """
    from ._snapshot import save_snapshot

    return save_snapshot(self, path)

subscribe

subscribe(callback: Callable[[], None]) -> None

Register a change-tick subscriber (no payload — the v1 protocol; the S2 reconciler diffs realize() output instead of consuming granular events).

Source code in src/apeGmsh/results/session/_session.py
def subscribe(self, callback: Callable[[], None]) -> None:
    """Register a change-tick subscriber (no payload — the v1
    protocol; the S2 reconciler diffs realize() output instead of
    consuming granular events)."""
    if callback not in self._subscribers:
        self._subscribers.append(callback)

Panes

A pane is a MeshView or a PlotView. Both carry a stable id (mesh-1, plot-2, …) — that id is how render(), the snapshot, the outline and the MCP verb address a pane, so a stale one raises KeyError rather than guessing.

A plot is a pane, not a dock hanging off a contour: several curves on one chart are one PlotView, and its series are live queries against Results evaluated at the cursor. plot.kind is fixed at creation (history, path or xy); plot.series and plot.cursor are assignable, and plot.name is a label.

session.remove_pane(pane_id) drops a pane and detaches it, so a handle you still hold stops ticking the session.

MeshView

apeGmsh.results.session.MeshView

MeshView(pane_id: str, name: Optional[str] = None, on_changed: _Notify = None)

A pane on the analysis mesh (§3). No BRep, no CAD.

Booted with the valid empty picture: grey mesh, mesh + outlines on, nodes / gauss off, no slots, no legends, unscoped, undeformed.

Source code in src/apeGmsh/results/session/_views.py
def __init__(
    self,
    pane_id: str,
    name: Optional[str] = None,
    on_changed: _Notify = None,
) -> None:
    self._id = str(pane_id)
    self._name = name
    self._notify = on_changed
    self._scope: Optional[Scope] = None
    self._deform: Optional[Deform] = None
    self._time: Optional[Instant] = None
    self._style = MeshStyle()
    self._overlay = False
    self._pick_target = "nodes"
    self._slots: dict[str, Slot] = {}
    self._legend_hidden: dict[str, bool] = {}
    self._legend_placement: dict[str, LegendPlacement] = {}
    self._clips: list[ViewClip] = []
    self._clip_ids = itertools.count(1)

id property

id: str

Stable pane id — how render, the MCP verb, the outline and the snapshot address this pane (§1).

scope property writable

scope: Optional[Scope]

One composition axis + checked names, or None = whole mesh. Scope chooses the ONE cell set every layer of this view is a function of (INV-MESH-1); it is a different knob from selection (§8).

deform property writable

deform: Optional[Deform]

The pose — off (None) or on (field, scale, mode?). Never a picture, never a legend (§4/§5).

time property writable

time: Optional[Instant]

This pane's own instant. Ignored while the session link is on (§7/§9 — set it here; the link ignores it); irrelevant for a mode pose, which has no instant.

style property writable

style: MeshStyle

The four style buttons (INV-MESH-4) — independent toggles of cells that are ON. They never change the cell set or the selection set (§8).

overlay property writable

overlay: bool

Undeformed overlay — this mesh at scale 0 in the Outlines style, same cell set (§3). Never CAD, never a second Geometry.

pick_target property writable

pick_target: str

The Nodes | Gauss radio (§8). Aims clicks and windows in THIS view only; it neither owns nor clears the session's one selection set.

is_mode_posed property

is_mode_posed: bool

Whether the pose is a mode shape — no instant, frozen under the session time link (§4/§7).

slots property

slots: dict[str, Slot]

Occupied categories → occupant, in §4 catalog order.

clips property

clips: tuple[ViewClip, ...]

This view's section planes, in creation order.

legends

legends() -> tuple[Legend, ...]

The colour scales this view carries — derived, never stored: one per distinct field over the occupied colour-mapped slots (INV-LEGEND-1/-2/-5). Same quantity on two slots → one scale. Deform on with every slot empty → zero legends.

Source code in src/apeGmsh/results/session/_views.py
def legends(self) -> tuple[Legend, ...]:
    """The colour scales this view carries — derived, never stored:
    one per distinct field over the occupied colour-mapped slots
    (INV-LEGEND-1/-2/-5). Same quantity on two slots → one scale.
    Deform on with every slot empty → zero legends."""
    fields: dict[str, list[str]] = {}
    for category in _COLOUR_ORDER:
        record = self._slots.get(category)
        if record is None:
            continue
        field = slot_field(category, record)
        fields.setdefault(field, []).append(category)
    return tuple(
        Legend(
            field=field,
            categories=tuple(categories),
            hidden=self._legend_hidden.get(field, False),
        )
        for field, categories in fields.items()
    )

set_legend_hidden

set_legend_hidden(field: str, hidden: bool = True) -> None

Hide/show one scale — view chrome (INV-LEGEND-3). Does not touch the slot: the picture stays painted. Refuses a field no occupied colour-mapped slot causes (that legend does not exist — INV-LEGEND-2).

Source code in src/apeGmsh/results/session/_views.py
def set_legend_hidden(self, field: str, hidden: bool = True) -> None:
    """Hide/show one scale — view chrome (INV-LEGEND-3). Does not
    touch the slot: the picture stays painted. Refuses a field no
    occupied colour-mapped slot causes (that legend does not
    exist — INV-LEGEND-2)."""
    live = {legend.field for legend in self.legends()}
    if field not in live:
        raise ValueError(
            f"No legend for field {field!r} on this view — legends "
            f"exist only for occupied colour-mapped slots (have: "
            f"{sorted(live)})."
        )
    hidden = bool(hidden)
    if self._legend_hidden.get(field, False) == hidden:
        return
    self._legend_hidden[field] = hidden
    self._changed()

legend_placement

legend_placement(field: str) -> Optional[LegendPlacement]

Where this view's scale for field was dragged, or None for the automatic stack (ADR 0098 A5.3). Realize reads this to seed the controller.

Source code in src/apeGmsh/results/session/_views.py
def legend_placement(self, field: str) -> Optional[LegendPlacement]:
    """Where this view's scale for ``field`` was dragged, or ``None``
    for the automatic stack (ADR 0098 A5.3). Realize reads this to
    seed the controller."""
    return self._legend_placement.get(field)

legend_placements

legend_placements() -> 'dict[str, LegendPlacement]'

Every hand placement on this view, keyed by field. A copy — the caller must not mutate the view's record.

Source code in src/apeGmsh/results/session/_views.py
def legend_placements(self) -> "dict[str, LegendPlacement]":
    """Every hand placement on this view, keyed by field. A copy —
    the caller must not mutate the view's record."""
    return dict(self._legend_placement)

set_legend_placement

set_legend_placement(field: str, anchor: 'tuple[float, float]', font_scale: Optional[float] = None) -> None

Record that one scale was placed by hand (ADR 0098 A5.3).

Refuses a field no occupied colour-mapped slot causes, the same rule :meth:set_legend_hidden enforces — placement is state ABOUT a legend, and a legend that does not exist cannot have any.

This records; it does not repaint. The gesture that calls it has already moved the live bar through the controller, and the record is what makes the move survive the next realize, a theme change and a snapshot. Placement is deliberately absent from the reconciler's structure signature (see :class:LegendPlacement), so a set here costs no realize — which also means a purely programmatic call on a live window takes effect at the next realize rather than immediately.

Source code in src/apeGmsh/results/session/_views.py
def set_legend_placement(
    self,
    field: str,
    anchor: "tuple[float, float]",
    font_scale: Optional[float] = None,
) -> None:
    """Record that one scale was placed by hand (ADR 0098 A5.3).

    Refuses a field no occupied colour-mapped slot causes, the same
    rule :meth:`set_legend_hidden` enforces — placement is state
    ABOUT a legend, and a legend that does not exist cannot have
    any.

    This records; it does not repaint. The gesture that calls it has
    already moved the live bar through the controller, and the
    record is what makes the move survive the next realize, a theme
    change and a snapshot. Placement is deliberately absent from the
    reconciler's structure signature (see :class:`LegendPlacement`),
    so a set here costs no realize — which also means a purely
    programmatic call on a live window takes effect at the next
    realize rather than immediately.
    """
    live = {legend.field for legend in self.legends()}
    if field not in live:
        raise ValueError(
            f"No legend for field {field!r} on this view — legends "
            f"exist only for occupied colour-mapped slots (have: "
            f"{sorted(live)})."
        )
    placement = LegendPlacement(anchor=anchor, font_scale=font_scale)
    if self._legend_placement.get(field) == placement:
        return
    self._legend_placement[field] = placement
    self._changed()

clear_legend_placement

clear_legend_placement(field: str) -> None

Return one scale to the automatic stack — what redock records. Unknown or unplaced fields are a no-op, so a redock of an already-docked legend costs nothing.

Source code in src/apeGmsh/results/session/_views.py
def clear_legend_placement(self, field: str) -> None:
    """Return one scale to the automatic stack — what ``redock``
    records. Unknown or unplaced fields are a no-op, so a redock of
    an already-docked legend costs nothing."""
    if self._legend_placement.pop(field, None) is None:
        return
    self._changed()

add_clip

add_clip(normal: Sequence[float], *, offset: float = 0.0, name: Optional[str] = None, active: bool = True, flipped: bool = False, gizmo_visible: bool = True) -> ViewClip

Cut this view with a half-space plane; returns the record.

normal is normalised on the way in and points into the half that SURVIVES; offset is the signed distance from the origin along it. A clip belongs to the view, not to a slot — every layer the pane draws is cut by it (ADR 0083 field shape, ADR 0098 ownership). Persisted section cuts boot as clips this way.

Source code in src/apeGmsh/results/session/_views.py
def add_clip(
    self,
    normal: Sequence[float],
    *,
    offset: float = 0.0,
    name: Optional[str] = None,
    active: bool = True,
    flipped: bool = False,
    gizmo_visible: bool = True,
) -> ViewClip:
    """Cut this view with a half-space plane; returns the record.

    ``normal`` is normalised on the way in and points into the half
    that SURVIVES; ``offset`` is the signed distance from the origin
    along it. A clip belongs to the view, not to a slot — every
    layer the pane draws is cut by it (ADR 0083 field shape, ADR
    0098 ownership). Persisted section cuts boot as clips this way.
    """
    clip = ViewClip(
        plane_id=f"clip-{next(self._clip_ids)}",
        name=name or f"Plane {len(self._clips) + 1}",
        normal=_unit(normal),
        offset=float(offset),
        active=bool(active),
        flipped=bool(flipped),
        gizmo_visible=bool(gizmo_visible),
    )
    self._clips.append(clip)
    self._changed()
    return clip

remove_clip

remove_clip(plane_id: str) -> None

Drop one clip by its plane_id. KeyError if absent — a silent no-op would leave the caller believing the model is uncut when it is not.

Source code in src/apeGmsh/results/session/_views.py
def remove_clip(self, plane_id: str) -> None:
    """Drop one clip by its ``plane_id``. ``KeyError`` if absent —
    a silent no-op would leave the caller believing the model is
    uncut when it is not."""
    for clip in self._clips:
        if clip.plane_id == plane_id:
            self._clips.remove(clip)
            self._changed()
            return
    raise KeyError(f"No clip {plane_id!r} on view {self._id!r}.")

set_clip

set_clip(plane_id: str, /, **changes: object) -> ViewClip

Replace fields of one clip (frozen record swap). plane_id is identity and cannot be changed.

Source code in src/apeGmsh/results/session/_views.py
def set_clip(self, plane_id: str, /, **changes: object) -> ViewClip:
    """Replace fields of one clip (frozen record swap). ``plane_id``
    is identity and cannot be changed."""
    if "plane_id" in changes:
        raise ValueError("ViewClip.plane_id is identity — not settable.")
    if "normal" in changes:
        changes["normal"] = _unit(changes["normal"])  # type: ignore[arg-type]
    if "offset" in changes:
        changes["offset"] = float(changes["offset"])  # type: ignore[arg-type]
    for i, clip in enumerate(self._clips):
        if clip.plane_id == plane_id:
            new = replace(clip, **changes)  # type: ignore[arg-type]
            if new == clip:
                return clip
            self._clips[i] = new
            self._changed()
            return new
    raise KeyError(f"No clip {plane_id!r} on view {self._id!r}.")

PlotView

apeGmsh.results.session.PlotView

PlotView(pane_id: str, kind: str = 'history', series: Sequence[PlotSeries] = (), name: Optional[str] = None, on_changed: _Notify = None)

A plot is a pane, not a dock of a contour (§6).

kind is fixed at creation; series are live queries against Results evaluated at the cursor. A history plot shows the whole record with the cursor as time; a path plot is evaluated at the current instant (§7).

Source code in src/apeGmsh/results/session/_views.py
def __init__(
    self,
    pane_id: str,
    kind: str = "history",
    series: Sequence[PlotSeries] = (),
    name: Optional[str] = None,
    on_changed: _Notify = None,
) -> None:
    if kind not in PLOT_KINDS:
        raise ValueError(
            f"PlotView.kind must be one of {PLOT_KINDS}; got "
            f"{kind!r}."
        )
    self._id = str(pane_id)
    self._kind = kind
    self._name = name
    self._notify = on_changed
    self._series: tuple[PlotSeries, ...] = ()
    self._cursor: Optional[Instant] = None
    self._series = self._coerce_series(series)

cursor property writable

cursor: Optional[Instant]

This plot's own instant. Rides the session instant while the link is on (§7).

The slot catalog

A mesh view carries slots, from a catalog of exactly seven categories:

Slot Record Carries Colour-mapped
contour Contour quantity + averaged / unaveraged yes
vector Vector quantity (nodal vectors and Gauss principal families) yes
gauss Gauss quantity at integration points yes
line Line member-diagram component (N / V / M / torsion) no
sand Sand quantity yes
loads Loads pattern (or None for the default) no
reactions Reactions no

Each category name in that first column is an assignable property of the mesh viewview.contour, view.vector, view.gauss, view.line, view.sand, view.loads, view.reactions — reading the occupant or None. (They are built by a factory, so they do not appear in the generated member list below; the table is their reference.)

The catalog is closed. Different categories stack; one category holds at most one occupant per view, and filling an occupied slot replaces the occupant — there is no "add a second contour". Assign None to clear, and a record of the wrong category refuses loudly.

view.contour = Contour("displacement_x")
view.contour = Contour("displacement_x", averaging="unaveraged")
view.slots
# {'contour': Contour(quantity='displacement_x', averaging='unaveraged')}

view.contour = None                   # clears
view.contour = Deform("displacement")
# TypeError: The 'contour' slot takes a Contour record or None; got Deform.

Fibers, shell layers and isochrones do not open an eighth slot: a new category is an ADR 0098 amendment, not a subclass.

apeGmsh.results.session._slots

Result-slot records — the closed catalog of ADR 0098 §4.

Seven categories, at most one occupant each per mesh view; different categories stack; filling an occupied slot replaces the occupant. The records here carry only what §4 puts inside the slot (quantity / component / averaging / pattern) — render style (cmap, clim, scale …) is not slot identity and is a later, additive widening.

Tokens are opaque at this layer: quantity / component / pattern are broker vocabulary strings resolved by S1's realize mapping (contour averaging → ContourStyle.averaging, the vector quantity → the vector_glyph / principal_glyph resolver, the line component → the LineForceStyle axis machinery). S0 stores them; it does not interpret them.

The catalog is CLOSED (amended ADR 0094 INV-10): a new category is an ADR 0098 amendment, not a subclass. Fibers, shell layers, isochrones and spring-as-its-own-kind do not open an eighth slot.

SLOT_CATALOG module-attribute

SLOT_CATALOG: dict[str, type] = {'contour': Contour, 'vector': Vector, 'gauss': Gauss, 'line': Line, 'sand': Sand, 'loads': Loads, 'reactions': Reactions}

COLOUR_MAPPED module-attribute

COLOUR_MAPPED: frozenset = frozenset({'contour', 'vector', 'gauss', 'sand'})

Slot dataclass

Slot()

Base for the seven slot records. Empty — categories share no fields; the base exists so a mesh view can say "this is a slot record" without enumerating the catalog.

Contour dataclass

Contour(quantity: str, averaging: str = 'averaged')

Bases: Slot

One heatmap: quantity + averaged | unaveraged (§4). Colour-mapped.

averaging uses the ADR vocabulary; S1 maps "unaveraged" to the existing ContourStyle.averaging="discrete" token. It is contour display state, not field identity — it never breaks the one-scale match with a Gauss slot of the same quantity (§5).

Vector dataclass

Vector(quantity: str)

Bases: Slot

Arrows: one quantity token spanning nodal vector fields AND Gauss principal families (§4 — vector_glyph and principal_glyph collapse to this one slot). Colour-mapped. S1's resolver routes the token to the right emit path.

Gauss dataclass

Gauss(quantity: str)

Bases: Slot

Values at integration points (§4). Colour-mapped; shares the contour's scale when the field matches (§5 — same quantity token). Gauss values are unaveraged by nature, so there is no averaging field here.

Line dataclass

Line(component: str)

Bases: Slot

Member diagrams — amplitude fill, NOT colour-mapped (§4/§5).

One component in v1 (N / V / M / torsion); a later widening may put several inside this one slot — never a second category.

Sand dataclass

Sand(quantity: str)

Bases: Slot

Sand field of a quantity (§4). Colour-mapped.

Loads dataclass

Loads(pattern: Optional[str] = None)

Bases: Slot

Applied loads — uniform glyphs, NOT colour-mapped (§4). pattern selects the pattern / stage; None = the default the realize layer resolves.

Reactions dataclass

Reactions()

Bases: Slot

Support reactions — uniform glyphs, NOT colour-mapped (§4). Nothing inside the slot.

slot_field

slot_field(category: str, record: Slot) -> Optional[str]

The field identity a slot contributes to the legend law (§5).

"Field matches" = the same quantity token; averaging is display state and does not participate. Non-colour-mapped categories emit nothing and have no field identity.

Source code in src/apeGmsh/results/session/_slots.py
def slot_field(category: str, record: Slot) -> Optional[str]:
    """The field identity a slot contributes to the legend law (§5).

    "Field matches" = the same quantity token; averaging is display
    state and does not participate. Non-colour-mapped categories emit
    nothing and have no field identity.
    """
    if category not in COLOUR_MAPPED:
        return None
    return record.quantity  # type: ignore[attr-defined]

Deform is a pose, not a slot

view.deform warps the mesh. It is deliberately outside the catalog, and the reason shows up in the legend: a pose is not a picture of a quantity, so a warped mesh with every slot empty draws no colour scale at all. Warping by displacement while contouring stress gives one scale, and it says stress.

Deform(mode=…) makes it a mode pose: the view then has no (stage, step) at all and is frozen under the session time link.

apeGmsh.results.session.Deform dataclass

Deform(field: str = 'displacement', scale: Optional[float] = None, mode: Optional[int] = None)

The pose of a mesh view — off (view.deform = None) or (field, scale, mode?). A pose, never a picture: it emits no legend (§5 INV-LEGEND-1).

scale=None means auto-fit at realize (the existing attach-time convention). mode set makes this a mode pose: the view has no (stage, step) and is frozen under the session time link (§4/§7). The mode index is an opaque token here; S1 resolves it against the broker's modal stage.

The legend law

Legends are derived, never stored: one scale per distinct field over the view's occupied colour-mapped slots. Two slots showing the same quantity therefore share one scale, clearing a slot destroys its scale, and hiding a scale is chrome — it hides the scale, not the picture, and does not clear the slot.

from apeGmsh.results.session import Gauss

view.deform = Deform("displacement", scale=5.0)
view.legends()                        # () — a pose causes no scale

view.contour = Contour("stress_xx")
view.gauss = Gauss("stress_xx")
[lg.field for lg in view.legends()]   # ['stress_xx'] — ONE scale
view.legends()[0].categories          # ('contour', 'gauss')

view.set_legend_hidden("stress_xx")   # chrome; the picture stays
view.set_legend_hidden("displacement_x")
# ValueError: No legend for field 'displacement_x' on this view — legends
# exist only for occupied colour-mapped slots (have: ['stress_xx']).

Nothing above reads a number: legends() is a function of the slots, so it answers on any view, bound to results or not.

apeGmsh.results.session.Legend dataclass

Legend(field: str, categories: tuple[str, ...], hidden: bool = False)

One derived colour scale of one mesh view (§5). Never stored: legends = f(occupied colour-mapped slots). field names the slot quantity (INV-LEGEND-4); categories are the slots that cause it — two slots of the same field share the one scale. hidden is view chrome (INV-LEGEND-3): hiding the scale does not hide the picture, and does not clear the slot.

Scope, style and clips

scope picks the ONE cell set every layer of the view is a function of — one composition axis (physical_groups, materials or element_types) plus checked names, never a boolean concrete AND hex. style is the four independent buttons over cells that are already on. Clips are the view's own section planes, added and edited through the view (the plane id is identity and cannot be reassigned).

from dataclasses import replace
from apeGmsh.results.session import Scope

view.scope = Scope("physical_groups", ("Cols",))
view.style = replace(view.style, nodes=True)

clip = view.add_clip((0, 0, 1), offset=1.0, name="mid-height")
view.set_clip(clip.plane_id, offset=2.0, flipped=True)
view.remove_clip(clip.plane_id)

A scope name the model does not carry is a ValueError at realize time, and it names what the model does have.

add_clip takes the plane normal (normalised for you) plus keyword offset, name, active, flipped and gizmo_visible, and returns the minted ViewClip. remove_clip(plane_id) drops one, raising KeyError on an unknown id.

apeGmsh.results.session.Scope dataclass

Scope(axis: str, names: Optional[tuple[str, ...]] = None)

What a mesh view draws — ONE composition axis plus one or more checked names on it, or all names on that axis (§3/§9; never a boolean concrete AND hex).

names=None = every name on the axis; view.scope = None = no scope at all (the whole analysis mesh). Names are opaque tokens here — resolution against the data (including the materials / element-type indexes) is S1/S3 work.

apeGmsh.results.session.MeshStyle dataclass

MeshStyle(mesh: bool = True, outlines: bool = True, nodes: bool = False, gauss: bool = False)

The four style buttons (§3 INV-MESH-4) — view chrome, not slots.

gauss here draws integration-point locations (required to click-pick Gauss); the gauss slot draws values. They must not draw two clouds — the realize layer owns that merge.

Boot picture: mesh + outlines on, nodes / gauss off (§3).

apeGmsh.results.session.ViewClip dataclass

ViewClip(plane_id: str, name: str, normal: tuple[float, float, float] = (1.0, 0.0, 0.0), offset: float = 0.0, active: bool = True, flipped: bool = False, gizmo_visible: bool = True)

One section plane of one view — copies the ADR 0083 ClipPlane field shape verbatim (the 0083 machinery is kept; ownership moved viewer → view). Frozen here: edits go through :meth:MeshView.set_clip, which replaces the record and ticks.

The six-active-planes GL cap is a render-time constraint enforced where planes meet a backend (S1/S3), not by the IR.

Time

An instant is (stage, step) — a name, so the step is a concrete recorded index and negative aliases like -1 are rejected. While the link is on, the session instant is the instant of every pane; turn it off and each pane keeps its own. effective_instant() is that law in one place, mode poses included.

s.time_linked                         # True
s.time = Instant("stage_0", 3)
s.effective_instant(view)             # Instant(stage='stage_0', step=3)

view.deform = Deform("displacement", mode=1)
s.effective_instant(view)             # None — a mode pose has no instant

apeGmsh.results.session.Instant dataclass

Instant(stage: str, step: int)

One recorded time sample: (stage, step).

stage is the stage id string (Results StageInfo.id); step is the 0-based recorded step index within that stage. Negative indices are rejected: an instant is a name, and letting -1 alias the last step would give one instant two unequal names (poison for the linked-time comparison and the S5 snapshot).

Selection and plots

The session holds one selection set, nodes XOR Gauss: a write of the other kind replaces the whole set rather than mixing the two. A mesh view's pick_target only aims clicks in that view; it neither owns nor clears the set.

"Select → new plot" copies the membership into concrete sources at creation — the plot does not track later edits to the selection.

from apeGmsh.results.session import PlotSeries, PlotSource

plot = s.add_plot(
    "history", series=[PlotSeries(PlotSource.node(9), "displacement_x")],
)

s.selection.set_nodes([5, 9])
s.selection.kind                      # 'nodes'
tip = s.add_plot_from_selection("displacement_x")
len(tip.series)                       # 2

realized = s.realize(pane=tip)        # arrays, no backend needed
realized.series[0].label              # 'node 5 · displacement_x'

apeGmsh.results.session.SessionSelection

SessionSelection(on_changed: Optional[Callable[[], None]] = None)

Nodes-XOR-Gauss surface over one real SelectionState.

Four writers (click, window, outline select-all, code) all land here; S0 ships the code writer. The per-view Nodes|Gauss radio (MeshView.pick_target) only AIMS clicks — it neither owns nor clears this set.

Source code in src/apeGmsh/results/session/_selection.py
def __init__(
    self, on_changed: Optional[Callable[[], None]] = None,
) -> None:
    self._state = SelectionState()
    if on_changed is not None:
        self._state.on_changed.append(on_changed)

state property

state: SelectionState

The one underlying store (ADR 0045 / INV-5). The Qt client binds here; nothing may construct a second store.

kind property

kind: Optional[str]

"nodes" | "gauss" | None (empty set). Derived from the set — the writers keep it homogeneous by law.

A heterogeneous or foreign-substrate set means some writer bypassed this surface (the raw store enforces no law); that is a programming error and reads FAIL LOUD rather than return a subset that silently misrepresents the store (probe H26).

nodes property

nodes: tuple[int, ...]

The selected node ids (empty unless kind == "nodes").

gauss property

gauss: tuple[tuple[int, int], ...]

The selected (element_id, gp_index) pairs (empty unless kind == "gauss").

set_nodes

set_nodes(node_ids: Iterable[int]) -> None

Replace the set with these nodes (one SET gesture).

Source code in src/apeGmsh/results/session/_selection.py
def set_nodes(self, node_ids: Iterable[int]) -> None:
    """Replace the set with these nodes (one ``SET`` gesture)."""
    self._state.select_batch(
        [node_target(i) for i in node_ids], replace=True,
    )

set_gauss

set_gauss(pairs: Iterable[tuple[int, int]]) -> None

Replace the set with these Gauss points (one SET).

Source code in src/apeGmsh/results/session/_selection.py
def set_gauss(self, pairs: Iterable[tuple[int, int]]) -> None:
    """Replace the set with these Gauss points (one ``SET``)."""
    self._state.select_batch(
        [gauss_target(e, gp) for e, gp in pairs], replace=True,
    )

add_nodes

add_nodes(node_ids: Iterable[int]) -> None

Extend a node set — or, on a Gauss set, REPLACE it (the §8 last-writer law: a write of the other kind replaces).

Source code in src/apeGmsh/results/session/_selection.py
def add_nodes(self, node_ids: Iterable[int]) -> None:
    """Extend a node set — or, on a Gauss set, REPLACE it (the §8
    last-writer law: a write of the other kind replaces)."""
    targets = [node_target(i) for i in node_ids]
    if not targets:
        return
    self._state.select_batch(targets, replace=self.kind == "gauss")

add_gauss

add_gauss(pairs: Iterable[tuple[int, int]]) -> None

Extend a Gauss set — or, on a node set, REPLACE it.

Source code in src/apeGmsh/results/session/_selection.py
def add_gauss(self, pairs: Iterable[tuple[int, int]]) -> None:
    """Extend a Gauss set — or, on a node set, REPLACE it."""
    targets = [gauss_target(e, gp) for e, gp in pairs]
    if not targets:
        return
    self._state.select_batch(targets, replace=self.kind == "nodes")

toggle_node

toggle_node(node_id: int) -> None

Ctrl+click on a node: add it, or drop it if already in.

On a Gauss set this is a write of the other kind, so the §8 last-writer law applies unchanged — the set is REPLACED by this one node rather than becoming heterogeneous.

Source code in src/apeGmsh/results/session/_selection.py
def toggle_node(self, node_id: int) -> None:
    """Ctrl+click on a node: add it, or drop it if already in.

    On a Gauss set this is a write of the other kind, so the §8
    last-writer law applies unchanged — the set is REPLACED by this
    one node rather than becoming heterogeneous.
    """
    if self.kind == "gauss":
        self.set_nodes([node_id])
        return
    self._state.toggle(node_target(node_id))

toggle_gauss

toggle_gauss(element_id: int, gp_index: int) -> None

Ctrl+click on a Gauss point — the mirror of :meth:toggle_node, replacing a node set.

Source code in src/apeGmsh/results/session/_selection.py
def toggle_gauss(self, element_id: int, gp_index: int) -> None:
    """Ctrl+click on a Gauss point — the mirror of
    :meth:`toggle_node`, replacing a node set."""
    if self.kind == "nodes":
        self.set_gauss([(element_id, gp_index)])
        return
    self._state.toggle(gauss_target(element_id, gp_index))

apeGmsh.results.session.PlotSource dataclass

PlotSource(kind: str, key: Union[int, tuple[int, int], str])

One series source (§6): a node, a Gauss point, a label, or a physical group. The session selection is NOT a source kind — the "select → New plot" flow COPIES the membership into concrete node/gauss sources at creation (a live alias is not v1). "Live" means live against Results at the cursor, not live membership.

apeGmsh.results.session.PlotSeries dataclass

PlotSeries(source: PlotSource, quantity: str)

One curve: a source + a quantity token. Several series on one chart are one plot view (§6).

Snapshot — the document on disk

session.snapshot() is the whole document as a JSON-safe dict: panes, slots, pose, the time link and every pane's own instant, the one selection set. Nothing derived (legends are a function of the slots) and nothing about a window — which is the point: an agent can redraw what a human arranged, with no Qt anywhere.

save_snapshot() writes it atomically to <results>.viewer-session.json beside the results file — the same file the window auto-saves on close.

from apeGmsh.results.session import load_snapshot

view.contour = Contour("displacement_x")
path = s.save_snapshot()              # <results>.viewer-session.json

restored = load_snapshot(path, results=results)
restored.session.panes[0].contour     # Contour(quantity='displacement_x', ...)
restored.notices                      # () — nothing degraded

load_snapshot returns a RestoredSession carrying notices: degradation is not refusal, so a snapshot naming a stage these results no longer have still restores its panes and slots, drops the instant, and says so. A file from the retired v13 viewer is not restorable — it is renamed aside to …​.viewer-session.json.legacy and never overwritten.

apeGmsh.results.session._snapshot

Session snapshot — the JSON of what the human built (ADR 0098 §11 S5).

One file, one session: panes, their slots, the pose, the time link and each pane's own instant, the one selection set. An agent can then draw a still of what a human arranged (render of a snapshot, S5c) and a pin can carry it (results_pin, S5b, under the record key session_snapshot).

The schema is frozen at version 1 against exactly what S0 shipped. S4 never widened the time surface — plan decision 9 landed on a one-stage-at-a-time scrubber, which is a WIDGET choice: Instant is (stage, step) under either traversal — so S5b's contract can publish without paying a second version bump.

Amendment 5 adds one OPTIONAL pane key, legend_placement, and does not bump the version either: a v1 file without it restores to the automatic legend stack (the pre-amendment behaviour, and the default), and a reader that does not know the key ignores it. Additive and compatible in both directions is the bar for staying at 1; anything that changed the meaning of an existing key would not clear it.

Two failure families, deliberately not alike (plan decision 15):

  • Schema / ontology violations refuse loudly. An unknown slot category is the loudest of them: the §4 catalog is CLOSED (amended ADR 0094 INV-10), and this refusal is that amendment's enforcement point, not a nicety. Restore builds the real frozen records, so every law S0 wrote — the closed catalog, the scope axes, the deform fields, the plot kinds, no negative steps — is enforced on the way in by the same validator a script hits. There is no second, weaker copy of the laws here.
  • Data mismatches degrade with a notice on RestoredSession.notices. An instant naming a stage these results no longer have drops to None (realize's documented "last stage, last step"); a stage rename must not cost the human every pane, slot and scope in the file. Silence is the only forbidden option.

What is NOT snapshot state: the SelectionLog op history (nothing realizes from it, and a replayed gesture has no model left to hit — the set restores as ONE honest SET write), the derived legends (§5: legends = f(occupied colour-mapped slots); only the per-field hidden chrome is state), and every widget geometry — the window is a projection, never truth.

The legacy gate: the S6a flip ADOPTED <results>.viewer-session.json (plan decision 11), so save-on-close now writes exactly where a v13 file from the retired window lives. A v13-shaped file therefore gets a notice and a .legacy rename-aside — and never an overwrite, of the original or of an existing .legacy. :func:legacy_shape is the bare predicate so S5c's MCP verb can refuse an old file without renaming anything (the rename belongs to the human flow), and :mod:apeGmsh.results.session._boot is where the window's open policy turns a refused rename into a notice plus a disarmed auto-save instead of a window that will not open.

RestoredSession dataclass

RestoredSession(session: ResultsSession, notices: tuple[str, ...] = ())

A restored session plus every degradation it survived.

notices is empty for a clean restore. It is a RETURN value, not a log line, because the caller decides how loud to be: the window shows them, the MCP verb reports them, a test asserts them. What no caller may do is not know.

SnapshotError

Bases: ValueError

The file is not a session this ontology has — a schema or ontology violation (unknown slot category, unknown pane kind, missing marker, unknown version). Loud by design.

LegacySessionFile

LegacySessionFile(path: 'str | Path', schema_version: Any = None)

Bases: SnapshotError

The file is the OLD viewer's v13 session (viewers.diagrams).

Raised by :func:load_snapshot when rename_legacy=False — the contract S5c's MCP verb needs: refuse an old-schema file, never rename it. The human flow renames it aside instead.

Source code in src/apeGmsh/results/session/_snapshot.py
def __init__(self, path: "str | Path", schema_version: Any = None) -> None:
    self.path = Path(path)
    self.schema_version = schema_version
    super().__init__(
        f"{self.path} is an old viewer session"
        + (
            f" (schema v{schema_version})"
            if schema_version is not None else ""
        )
        + " from the retired diagram ontology; ADR 0098 does not "
        "restore it. Open it in the results window to have it "
        "renamed aside, or point at a "
        f"'{_SNAPSHOT_SUFFIX}' snapshot."
    )

snapshot

snapshot(session: ResultsSession) -> dict

This session as a JSON-safe dict (schema :data:SNAPSHOT_VERSION).

Panes in creation order; nothing derived is stored (legends are a function of the slots, §5) and nothing about a window is stored.

Source code in src/apeGmsh/results/session/_snapshot.py
def snapshot(session: ResultsSession) -> dict:
    """This session as a JSON-safe dict (schema
    :data:`SNAPSHOT_VERSION`).

    Panes in creation order; nothing derived is stored (legends are a
    function of the slots, §5) and nothing about a window is stored.
    """
    return {
        "kind": SNAPSHOT_KIND,
        "version": SNAPSHOT_VERSION,
        "saved_at": datetime.datetime.now(
            datetime.timezone.utc,
        ).isoformat(),
        "results_path": _results_path_str(session.results),
        "time": _dump_instant(session.time),
        "time_linked": session.time_linked,
        # Both halves of the §7 state, always: `time_linked` alone would
        # restore an unlinked session with every pane silently relinked
        # to one instant (plan decision 15). Each pane's own instant
        # rides on the pane, above.
        "selection": _dump_selection(session),
        "panes": [
            _dump_mesh_view(pane) if isinstance(pane, MeshView)
            else _dump_plot_view(pane)
            for pane in session.panes
        ],
    }

save_snapshot

save_snapshot(session: ResultsSession, path: 'str | Path | None' = None) -> Path

Write this session's snapshot; returns the path written.

Atomically (ADR 0095 INV-16, via the shared :func:apeGmsh._atomic_io.atomic_write_text): a reader may see the file missing mid-replace, never a truncated JSON body. path=None uses :func:default_snapshot_path, which needs a Results opened from disk.

Source code in src/apeGmsh/results/session/_snapshot.py
def save_snapshot(
    session: ResultsSession, path: "str | Path | None" = None,
) -> Path:
    """Write this session's snapshot; returns the path written.

    Atomically (ADR 0095 INV-16, via the shared
    :func:`apeGmsh._atomic_io.atomic_write_text`): a reader may see the
    file missing mid-replace, never a truncated JSON body. ``path=None``
    uses :func:`default_snapshot_path`, which needs a Results opened
    from disk.
    """
    if path is None:
        results_path = _results_path_str(session.results)
        if results_path is None:
            raise ValueError(
                "This session's Results was not opened from a file, so "
                "there is no <results>.viewer-session.json to default "
                "to — "
                "pass an explicit path."
            )
        path = default_snapshot_path(results_path)
    text = json.dumps(
        snapshot(session), indent=2, ensure_ascii=False,
    ) + "\n"
    return atomic_write_text(path, text)

load_snapshot

load_snapshot(path: 'str | Path', results: 'Optional[Results]' = None, *, rename_legacy: bool = True) -> Optional[RestoredSession]

Read a snapshot from disk. None when the legacy gate fired.

An old v13-shaped viewer session is not restorable (ADR 0098 Consequences). In the human flow (rename_legacy=True) it earns a notice and a .legacy rename-aside, and this returns None — the caller boots a fresh session. With rename_legacy=False (S5c's MCP verb) it raises :class:LegacySessionFile and touches nothing on disk.

Source code in src/apeGmsh/results/session/_snapshot.py
def load_snapshot(
    path: "str | Path",
    results: "Optional[Results]" = None,
    *,
    rename_legacy: bool = True,
) -> Optional[RestoredSession]:
    """Read a snapshot from disk. ``None`` when the legacy gate fired.

    An old v13-shaped viewer session is not restorable (ADR 0098
    Consequences). In the human flow (``rename_legacy=True``) it earns
    a notice and a ``.legacy`` rename-aside, and this returns ``None``
    — the caller boots a fresh session. With ``rename_legacy=False``
    (S5c's MCP verb) it raises :class:`LegacySessionFile` and touches
    nothing on disk.
    """
    p = Path(path)
    data = json.loads(p.read_text(encoding="utf-8"))
    if legacy_shape(data):
        version = data.get("schema_version")
        if not rename_legacy:
            raise LegacySessionFile(p, version)
        dest = rename_legacy_aside(p)
        print(
            f"[session] {p.name} is an old viewer session (schema "
            f"v{version}) from the retired diagram ontology; ADR 0098 "
            f"does not restore it. Renamed aside to {dest.name} — "
            f"nothing was overwritten, and a fresh session is used."
        )
        return None
    return restore_snapshot(data, results=results)

restore_snapshot

restore_snapshot(data: Any, results: 'Optional[Results]' = None) -> RestoredSession

Build a session from a snapshot dict.

results= binds the broker the restored session presents; pass None for an IR-only restore (nothing to validate instants against, so they restore verbatim).

Source code in src/apeGmsh/results/session/_snapshot.py
def restore_snapshot(
    data: Any, results: "Optional[Results]" = None,
) -> RestoredSession:
    """Build a session from a snapshot dict.

    ``results=`` binds the broker the restored session presents; pass
    ``None`` for an IR-only restore (nothing to validate instants
    against, so they restore verbatim).
    """
    payload = _require_dict(data, "A session snapshot")
    kind = payload.get("kind")
    if kind != SNAPSHOT_KIND:
        raise SnapshotError(
            f"Not an apeGmsh session snapshot: expected "
            f"kind={SNAPSHOT_KIND!r}, got {kind!r}."
            + (
                " This looks like the OLD viewer's session (it carries "
                "'schema_version'); ADR 0098 does not restore it."
                if legacy_shape(payload) else ""
            )
        )
    version = payload.get("version")
    if version != SNAPSHOT_VERSION:
        raise SnapshotError(
            f"Session snapshot schema v{version!r} — this build reads "
            f"v{SNAPSHOT_VERSION}."
        )

    notices: list[str] = []
    session = ResultsSession(results=results)
    for raw_pane in payload.get("panes") or ():
        pane_raw = _require_dict(raw_pane, "A pane")
        pane_kind = pane_raw.get("pane")
        if pane_kind == "mesh":
            pane = _restore_mesh_view(pane_raw, notices)
        elif pane_kind == "plot":
            pane = _restore_plot_view(pane_raw)
        else:
            raise SnapshotError(
                f"Unknown pane kind {pane_kind!r} — a session has mesh "
                f"views and plot views (ADR 0098 §3/§6)."
            )
        session._adopt_pane(pane)

    session.time_linked = bool(payload.get("time_linked", True))
    session.time = _read_instant(payload.get("time"), "Session time")
    _restore_selection(session, payload.get("selection"))

    # TWO counters, not one (S0's runway note): the session's pane ids
    # here, and each view's own clip ids in _adopt_clips above. Restore
    # mesh-3, add a view, and without this you get a SECOND mesh-1.
    session._reseed_ids()

    if results is not None:
        _validate_instants(session, results, notices)
    return RestoredSession(session=session, notices=tuple(notices))

default_snapshot_path

default_snapshot_path(results_path: 'str | Path') -> Path

<results>.viewer-session.json beside the results file.

The old viewer's name, adopted at the S6a flip (plan decision 11) now that nothing else writes it. A file already at this path may therefore be a v13 session from the retired window — which is what :func:legacy_shape and :func:rename_legacy_aside below are for.

Source code in src/apeGmsh/results/session/_snapshot.py
def default_snapshot_path(results_path: "str | Path") -> Path:
    """``<results>.viewer-session.json`` beside the results file.

    The old viewer's name, adopted at the S6a flip (plan decision 11)
    now that nothing else writes it. A file already at this path may
    therefore be a v13 session from the retired window — which is what
    :func:`legacy_shape` and :func:`rename_legacy_aside` below are for.
    """
    p = Path(results_path)
    return p.with_suffix(p.suffix + _SNAPSHOT_SUFFIX)

legacy_shape

legacy_shape(data: Any) -> bool

Whether data is the OLD viewer's session (v13 and friends).

The bare predicate, so S5c's MCP verb can refuse an old-schema file while renaming nothing. Told apart by keys, never by version arithmetic: the old envelope carries an int schema_version plus the retired ontology's diagrams / geometries blocks, and no :data:SNAPSHOT_KIND marker.

Source code in src/apeGmsh/results/session/_snapshot.py
def legacy_shape(data: Any) -> bool:
    """Whether ``data`` is the OLD viewer's session (v13 and friends).

    The bare predicate, so S5c's MCP verb can refuse an old-schema file
    while renaming nothing. Told apart by keys, never by version
    arithmetic: the old envelope carries an int ``schema_version`` plus
    the retired ontology's ``diagrams`` / ``geometries`` blocks, and no
    :data:`SNAPSHOT_KIND` marker.
    """
    if not isinstance(data, dict) or data.get("kind") == SNAPSHOT_KIND:
        return False
    version = data.get("schema_version")
    return (
        isinstance(version, int)
        and not isinstance(version, bool)
        and ("diagrams" in data or "geometries" in data)
    )

rename_legacy_aside

rename_legacy_aside(path: 'str | Path') -> Path

Move an old viewer session to <path>.legacy. Never destroys.

The ADR keeps the old file so a one-shot importer would still have its input, which is worth nothing if the second open overwrites the first rename. So an existing .legacy is REFUSED, not replaced — and the destination is reserved with an exclusive create before the replace, making that guarantee atomic rather than advisory.

Source code in src/apeGmsh/results/session/_snapshot.py
def rename_legacy_aside(path: "str | Path") -> Path:
    """Move an old viewer session to ``<path>.legacy``. Never destroys.

    The ADR keeps the old file so a one-shot importer would still have
    its input, which is worth nothing if the second open overwrites the
    first rename. So an existing ``.legacy`` is REFUSED, not replaced —
    and the destination is reserved with an exclusive create before the
    replace, making that guarantee atomic rather than advisory.
    """
    src = Path(path)
    dest = Path(str(src) + LEGACY_SUFFIX)
    try:
        fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
    except FileExistsError:
        raise FileExistsError(
            f"Refusing to move {src.name} aside: {dest.name} already "
            f"exists. An older viewer session was renamed aside here "
            f"before, and overwriting it would destroy it. Move or "
            f"delete {dest} yourself, then reopen."
        ) from None
    os.close(fd)
    try:
        # Atomic, and the only thing it can overwrite is the empty
        # placeholder we just proved we were the ones to create. Through
        # the shared retry because this is the same syscall, on the same
        # platform, with the same transient-denial behaviour that
        # atomic_write_text has always guarded against — an asymmetry
        # that only shows up on a loaded machine, which is the worst
        # kind of thing to leave to chance on a file a human cares
        # about.
        replace_with_retry(src, dest)
    except OSError:
        try:
            os.unlink(dest)  # never leave a 0-byte file blocking a retry
        except OSError:
            pass
        raise
    return dest

See also