Seeds: " + ", ".join(f"{escape(str(k))} = {escape(str(v))}"
+ for k, v in record.seeds.items()) + "
")
+ return "\n".join(out)
+
+
+def _cite_section(workflow: Workflow) -> str:
+ entries = [citation.app_entry()]
+ for node in workflow.order():
+ for called in algorithm_calls(workflow.spec(node)):
+ for item in citation.entries_for_module(called.module):
+ if all(item.key != e.key for e in entries):
+ entries.append(item)
+ bib = "\n\n".join(e.bibtex() for e in entries)
+ return ("
How to cite
"
+ f"
{escape(citation.app_text())}
"
+ f"BibTeX, with the papers the algorithms follow
{escape(bib)}
")
+
+
+def write_report(path: str | Path, **kwargs) -> Path:
+ """Write :func:`html_report` to ``path``."""
+ path = Path(path)
+ path.write_text(html_report(**kwargs), encoding="utf-8")
+ return path
+
+
+__all__ = ["html_report", "write_report", "CSS"]
diff --git a/openprocess/flow/reproducibility.py b/openprocess/flow/reproducibility.py
new file mode 100644
index 0000000..8097f43
--- /dev/null
+++ b/openprocess/flow/reproducibility.py
@@ -0,0 +1,62 @@
+"""Is this analysis reproducible, right now? One status for the header.
+
+The record (:mod:`openprocess.flow.record`) is a file; this turns it into a
+visible promise. :func:`status` compares the record with the world now
+(the inputs' fingerprints, the box files, the seeds, the versions) and with
+the latest run (every box's result against its recorded fingerprint), and
+says one of:
+
+* **Not recorded yet** -- the analysis has never been saved with a run;
+* **Recorded · this run reproduces it** -- every result matches the record and
+ nothing it depends on has changed;
+* **Recorded · n changes since** -- an input file, a box file, a seed or a
+ version differs from the record (a re-run may give other numbers);
+* **This run differs from the record** -- the same inputs gave another result.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from .record import Record, check, differences
+from .runner import DONE, Run
+from .workflow import Workflow
+
+
+@dataclass
+class Status:
+ #: "unrecorded", "reproduces", "unchecked", "changed" or "differs".
+ state: str
+ headline: str
+ details: list[str] = field(default_factory=list)
+
+ @property
+ def tone(self) -> str:
+ return {"reproduces": "good", "changed": "warning", "differs": "critical"}.get(self.state, "muted")
+
+
+def status(workflow: Workflow, record: Record | None, run: Run | None, folder=None) -> Status:
+ """The analysis's reproducibility, from its record and its latest run."""
+ if record is None or not record.results:
+ return Status("unrecorded", "Not recorded yet", [
+ "Save the analysis (it is saved as you work in a folder): the record keeps the inputs' "
+ "fingerprints, the versions and the seeds, so the numbers can be checked again later."])
+ changed = differences(record, workflow, folder)
+ mismatches = check(workflow, record, run) if run is not None else []
+ compared = [node_id for node_id in record.results
+ if run is not None and run.result(node_id, 0) is not None
+ and run.result(node_id, 0).status == DONE]
+ if mismatches:
+ return Status("differs", "This run differs from the record", mismatches + changed)
+ if changed:
+ count = len(changed)
+ return Status("changed", f"Recorded · {count} change{'s' if count != 1 else ''} since", changed)
+ if not compared:
+ return Status("unchecked", "Recorded · not run since", [
+ "Run ▶ and every result is checked against its recorded fingerprint."])
+ return Status("reproduces", "Recorded · this run reproduces it", [
+ f"{len(compared)} result{'s' if len(compared) != 1 else ''} match the record's fingerprints, and "
+ "nothing the analysis depends on has changed."])
+
+
+__all__ = ["Status", "status"]
diff --git a/openprocess/flow/runner.py b/openprocess/flow/runner.py
index 018395b..de2dacd 100644
--- a/openprocess/flow/runner.py
+++ b/openprocess/flow/runner.py
@@ -123,6 +123,16 @@ def clear(self) -> None:
self.entries.clear()
+def _file_signature(value) -> str:
+ """``size:mtime_ns`` of a file setting's file, or ``missing``."""
+ from pathlib import Path
+ try:
+ stat = Path(value).stat()
+ except OSError:
+ return "missing"
+ return f"{stat.st_size}:{stat.st_mtime_ns}"
+
+
def settings_key(settings: dict[str, Any]) -> str:
return json.dumps({k: (str(v) if not isinstance(v, (int, float, bool, str, type(None), list, dict)) else v)
for k, v in sorted(settings.items())}, sort_keys=True, default=str)
@@ -267,6 +277,12 @@ def _run_node(self, workflow: Workflow, node: Node, run: Run, variant: int,
result.status = IDLE
result.message = f"Choose the {setting.name} in Settings"
return result
+ # A file setting is keyed by the file as it is now (size and time), not
+ # just its name: an input edited on disk gets computed again, never
+ # served from the cache as if nothing had happened.
+ for setting in spec.settings:
+ if setting.kind == "path" and settings.get(setting.name) not in (None, ""):
+ keys.append(f"{setting.name}={_file_signature(settings[setting.name])}")
result.key = hashlib.sha256(
(spec.fingerprint + "|" + settings_key(settings) + "|" + "|".join(keys)).encode()).hexdigest()
cached = self.cache.get(result.key)
diff --git a/openprocess/flow/snapshots.py b/openprocess/flow/snapshots.py
new file mode 100644
index 0000000..7780efd
--- /dev/null
+++ b/openprocess/flow/snapshots.py
@@ -0,0 +1,179 @@
+"""Snapshots of an analysis: every finished run, kept, so two can be compared.
+
+A sweep shows the effect of one setting over a range. Across sessions the
+question is wider: what did I change, and what did it do? A
+:class:`Snapshot` keeps, for one finished run, every box's settings and key
+figure and a fingerprint of its result; a :class:`History` keeps the last
+few snapshots with the workflow file. :func:`describe_change` says what
+changed between two runs ("Inductive Miner: noise 0 → 0.2"), and
+:func:`compare` lines the two up box by box. A snapshot's settings can be
+put back on the workflow, so a run can be returned to.
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+from dataclasses import dataclass, field
+from typing import Any
+
+from .figures import tile_parts
+from .record import fingerprint
+from .runner import DONE, Run
+from .sweep import Sweep, is_sweep
+from .workflow import Workflow, _json_value
+
+#: How many snapshots a history keeps.
+LIMIT = 20
+
+
+@dataclass
+class Snapshot:
+ taken: str # ISO time
+ label: str = "" # what changed before this run
+ #: box id -> {setting: JSON value} (a sweep as {"sweep": [...]})
+ settings: dict[str, dict[str, Any]] = field(default_factory=dict)
+ titles: dict[str, str] = field(default_factory=dict)
+ statuses: dict[str, str] = field(default_factory=dict)
+ #: box id -> (key figure, line under it), for boxes that have one
+ figures: dict[str, tuple[str, str]] = field(default_factory=dict)
+ #: box id -> result fingerprint (done boxes only)
+ fingerprints: dict[str, str] = field(default_factory=dict)
+
+ def to_dict(self) -> dict:
+ return {"taken": self.taken, "label": self.label, "settings": self.settings, "titles": self.titles,
+ "statuses": self.statuses, "figures": {k: list(v) for k, v in self.figures.items()},
+ "fingerprints": self.fingerprints}
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "Snapshot":
+ return cls(str(data.get("taken", "")), str(data.get("label", "")), dict(data.get("settings", {})),
+ dict(data.get("titles", {})), dict(data.get("statuses", {})),
+ {k: (str(v[0]), str(v[1])) for k, v in data.get("figures", {}).items()
+ if isinstance(v, (list, tuple)) and len(v) == 2},
+ dict(data.get("fingerprints", {})))
+
+ @property
+ def done(self) -> int:
+ return sum(1 for s in self.statuses.values() if s == DONE)
+
+
+def _settings_json(workflow: Workflow) -> dict[str, dict[str, Any]]:
+ return {node.id: {k: (v.to_json() if is_sweep(v) else _json_value(v)) for k, v in node.settings.items()}
+ for node in workflow.nodes.values()}
+
+
+def take(workflow: Workflow, run: Run | None, previous: "Snapshot | None" = None) -> Snapshot:
+ """A snapshot of ``run``; its label says what changed since ``previous``."""
+ snapshot = Snapshot(_dt.datetime.now().isoformat(timespec="seconds"), settings=_settings_json(workflow),
+ titles={node.id: workflow.title(node.id) for node in workflow.nodes.values()})
+ for node in workflow.nodes.values():
+ result = run.result(node) if run is not None else None
+ snapshot.statuses[node.id] = result.status if result is not None else "waiting"
+ if result is not None and result.status == DONE:
+ parts = tile_parts(result.value)
+ if parts is not None:
+ snapshot.figures[node.id] = parts
+ try:
+ snapshot.fingerprints[node.id] = fingerprint(result.value)
+ except Exception: # noqa: BLE001 - a value that cannot be fingerprinted is left out
+ pass
+ snapshot.label = describe_change(previous, snapshot)
+ return snapshot
+
+
+def _show(value) -> str:
+ if isinstance(value, dict) and "sweep" in value:
+ return "sweep " + ", ".join(str(v) for v in value["sweep"][:4]) + ("…" if len(value["sweep"]) > 4 else "")
+ return str(value)
+
+
+def setting_changes(before: Snapshot | None, after: Snapshot) -> list[str]:
+ """"Inductive Miner: noise 0 → 0.2", one per setting that differs."""
+ if before is None:
+ return []
+ out = []
+ for node_id, settings in after.settings.items():
+ then = before.settings.get(node_id)
+ if then is None:
+ continue
+ for name, value in settings.items():
+ if name in then and then[name] != value:
+ out.append(f"{after.titles.get(node_id, node_id)}: {name} {_show(then[name])} → {_show(value)}")
+ return out
+
+
+def describe_change(before: Snapshot | None, after: Snapshot) -> str:
+ """What changed between two runs, in a few words."""
+ if before is None:
+ return "First run"
+ parts = setting_changes(before, after)
+ added = [after.titles.get(n, n) for n in after.settings if n not in before.settings]
+ removed = [before.titles.get(n, n) for n in before.settings if n not in after.settings]
+ parts += [f"+ {name}" for name in added] + [f"− {name}" for name in removed]
+ if parts:
+ return "; ".join(parts)
+ same = all(after.fingerprints.get(n) == before.fingerprints.get(n) for n in after.fingerprints)
+ return "Run again, same results" if same and after.fingerprints else "Run again"
+
+
+@dataclass
+class Row:
+ node_id: str
+ title: str
+ a: str # A's key figure ("" when A had no such box)
+ b: str
+ changed: bool
+ changes: list[str] = field(default_factory=list)
+
+
+def compare(a: Snapshot, b: Snapshot) -> list[Row]:
+ """Box by box: the key figure in A and in B, and whether the result differs."""
+ rows = []
+ order = list(a.settings) + [n for n in b.settings if n not in a.settings]
+ for node_id in order:
+ title = b.titles.get(node_id) or a.titles.get(node_id, node_id)
+ fa, fb = a.figures.get(node_id), b.figures.get(node_id)
+ in_both = node_id in a.fingerprints and node_id in b.fingerprints
+ changed = (a.fingerprints.get(node_id) != b.fingerprints.get(node_id)) if in_both else (fa != fb)
+ changes = [c for c in setting_changes(a, b) if c.startswith(title + ":")]
+ rows.append(Row(node_id, title, fa[0] if fa else "", fb[0] if fb else "", changed, changes))
+ return rows
+
+
+@dataclass
+class History:
+ snapshots: list[Snapshot] = field(default_factory=list) # oldest first
+
+ @property
+ def latest(self) -> Snapshot | None:
+ return self.snapshots[-1] if self.snapshots else None
+
+ def add(self, snapshot: Snapshot) -> bool:
+ """Keep ``snapshot`` unless it repeats the latest (same settings, same
+ results); at most :data:`LIMIT`. Returns whether it was kept."""
+ latest = self.latest
+ if latest is not None and latest.settings == snapshot.settings \
+ and latest.fingerprints == snapshot.fingerprints and set(latest.statuses) == set(snapshot.statuses):
+ return False
+ self.snapshots.append(snapshot)
+ del self.snapshots[:-LIMIT]
+ return True
+
+ def to_dict(self) -> list[dict]:
+ return [s.to_dict() for s in self.snapshots]
+
+ @classmethod
+ def from_dict(cls, data) -> "History":
+ return cls([Snapshot.from_dict(d) for d in (data or []) if isinstance(d, dict)][-LIMIT:])
+
+
+def settings_to_apply(snapshot: Snapshot, node_id: str) -> dict[str, Any]:
+ """A snapshot's settings for one box, as :meth:`Workflow.set` takes them."""
+ out = {}
+ for name, value in snapshot.settings.get(node_id, {}).items():
+ out[name] = Sweep(tuple(value["sweep"])) if isinstance(value, dict) and "sweep" in value else value
+ return out
+
+
+__all__ = ["Snapshot", "History", "Row", "take", "compare", "describe_change", "setting_changes",
+ "settings_to_apply", "LIMIT"]
diff --git a/openprocess/flow/types.py b/openprocess/flow/types.py
index 4246d80..0ec49ea 100644
--- a/openprocess/flow/types.py
+++ b/openprocess/flow/types.py
@@ -28,6 +28,7 @@ class Embedding: ...
from ..mining.conformance.alignments import AlignmentResult
from ..mining.conformance.token_replay import ReplayResult
from ..mining.dfg import DFG
+from ..mining.ocel import OCDFG, OCEL
from ..mining.footprint import Footprint
from ..mining.log import EventLog, SimpleLog
from ..mining.petrinet import Marking, PetriNet
@@ -268,6 +269,8 @@ def is_known(cls) -> bool:
(ProcessTree, "tree", "Process tree", "#2e9e4f"),
(TransitionSystem, "ts", "Transition system", "#b0430f"),
(DFG, "dfg", "Directly-follows graph", "#b0430f"),
+ (OCEL, "ocel", "Object-centric log", "#0f6e9e"),
+ (OCDFG, "ocdfg", "Object-centric map", "#b0430f"),
(Footprint, "footprint", "Footprint", "#b0430f"),
(ReplayResult, "replay", "Replay", "#8250c8"),
(AlignmentResult, "alignments", "Alignments", "#8250c8"),
@@ -283,7 +286,7 @@ def is_known(cls) -> bool:
register_type(_cls, _key, _name, _colour)
__all__ = [
- "AlignmentResult", "Any", "CPNet", "DFG", "Dataset", "EventLog", "Figure", "Footprint",
+ "AlignmentResult", "Any", "CPNet", "DFG", "Dataset", "EventLog", "Figure", "Footprint", "OCDFG", "OCEL",
"Marking", "PetriNet", "Predictions", "Predictor", "ProcessTree", "Regions", "ReplayResult",
"Scores", "SimpleLog", "Table", "Text", "TransitionSystem", "TypeInfo", "TYPES",
"is_known", "register_type", "type_by_key", "type_info",
diff --git a/openprocess/gui/flow/page.py b/openprocess/gui/flow/page.py
index ad5c92f..62e7355 100644
--- a/openprocess/gui/flow/page.py
+++ b/openprocess/gui/flow/page.py
@@ -34,6 +34,7 @@
)
from ...flow import record as records
+from ...flow import reproducibility, snapshots
from ...flow.library import Library
from ...flow.runner import BLOCKED, DONE, FAILED, IDLE, RUNNING, WAITING, Cache, Result, Run, Runner
from ...flow.types import EventLog, Figure, PetriNet, Scores, Table
@@ -44,6 +45,7 @@
from ..studio.workers import run_in_background
from .canvas import WorkflowScene, WorkflowView
from .picker import BoxPicker, input_box_for
+from .runs import RunsWidget
from .summary import SummaryWidget
from .viewers import SettingsWidget, code_widget, how_widget, pop_out, result_widget, status_text
@@ -93,6 +95,8 @@ def brief(value) -> str:
class WorkflowPage(QWidget):
status = Signal(str)
saved = Signal()
+ #: A run finished (not stopped): the record wants updating (the window autosaves in a folder).
+ ran = Signal()
#: Something changed that should be saved (the window autosaves in a folder).
edited = Signal()
#: A result should open as a page of its own (a LogDocument, ModelDocument, CpnDocument).
@@ -141,10 +145,15 @@ def __init__(self, document, library: Library, folder: str | Path | None = None,
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self.header = PageHeader(self.workflow.name, self._subtitle())
+ #: Reproducibility, as last computed (a run finishing, a save): see refresh_title.
+ self._repro = reproducibility.status(self.workflow, getattr(document, "record", None), None, self.folder)
# Canvas: the boxes and wires. Summary: every result's figure, and the process map.
- self.summary_switch = SegmentedControl(["Canvas", "Summary"], compact=True)
+ self.summary_switch = SegmentedControl(["Canvas", "Summary", "Runs"], compact=True)
self.summary_switch.setToolTip("Canvas: the boxes and wires.\nSummary: every result's key "
- "figure, and the process map of the first log.")
+ "figure, and the process map.\nRuns: every finished run, two compared.")
+ record = getattr(document, "record", None)
+ #: The last runs, with what changed before each (kept in the workflow file).
+ self.history = snapshots.History.from_dict(record.history if record is not None else [])
self.summary_switch.changed.connect(self.show_view)
self.header.actions.addWidget(self.summary_switch)
self.keep_button = button("Keep", self.keep_requested.emit, kind="primary",
@@ -165,9 +174,12 @@ def __init__(self, document, library: Library, folder: str | Path | None = None,
self.more_menu.addAction("Export experiment…", self.export_experiment).setToolTip(
"A zip with the workflow, its inputs, your boxes, every result and a README: "
"supplementary material for a paper")
+ self.more_menu.addAction("Export report…", self.export_report).setToolTip(
+ "One HTML page: every box's result, how it got there, the code it ran, the papers it "
+ "follows, what it read, and how to cite. For a supervisor, a reviewer, a hand-in")
self.more_menu.addSeparator()
self.more_menu.addAction("Save", self.save).setToolTip("Save the workflow and its record")
- self.more_button = button("⋯", self._show_more_menu, tooltip="Re-run, Record, Export experiment, Save")
+ self.more_button = button("⋯", self._show_more_menu, tooltip="Re-run, Record, Export experiment, Export report, Save")
self.more_button.setObjectName("moreButton")
self.more_button.setFixedWidth(40)
self.header.actions.addWidget(self.more_button)
@@ -220,9 +232,13 @@ def __init__(self, document, library: Library, folder: str | Path | None = None,
self.summary.tile_clicked.connect(self._tile_clicked)
summary_wrapper = QWidget()
summary_wrapper.setLayout(vbox(scroll(self.summary), margins=(20, 0, 20, 16)))
+ self.runs = RunsWidget(self)
+ runs_wrapper = QWidget()
+ runs_wrapper.setLayout(vbox(scroll(self.runs), margins=(20, 0, 20, 16)))
self.body = QStackedWidget()
self.body.addWidget(wrapper)
self.body.addWidget(summary_wrapper)
+ self.body.addWidget(runs_wrapper)
root.addWidget(self.body, 1)
self._picker: BoxPicker | None = None
@@ -254,7 +270,7 @@ def close_panel(self) -> None:
self.select(None)
def show_view(self, index: int) -> None:
- """Canvas (0) or Summary (1); the Summary is rebuilt from the run when shown."""
+ """Canvas (0), Summary (1) or Runs (2); the latter two are rebuilt when shown."""
if self.body.currentIndex() != index:
self.body.setCurrentIndex(index)
motion.lift(self.body)
@@ -264,6 +280,33 @@ def show_view(self, index: int) -> None:
self.summary_switch.blockSignals(False)
if index == 1:
self.summary.refresh()
+ elif index == 2:
+ self.runs.refresh()
+
+ def apply_snapshot(self, snapshot: snapshots.Snapshot) -> None:
+ """Put a run's settings back on the boxes that still exist, and run."""
+ changed = []
+ for node_id in list(self.workflow.nodes):
+ values = snapshots.settings_to_apply(snapshot, node_id)
+ if not values:
+ continue
+ before = dict(self.workflow.nodes[node_id].settings)
+ try:
+ self.workflow.set(node_id, **values)
+ except (ValueError, TypeError) as error:
+ self.status.emit(str(error))
+ continue
+ if self.workflow.nodes[node_id].settings != before:
+ changed.append(node_id)
+ if not changed:
+ self.status.emit("Those are the settings already")
+ return
+ self._mark_edited()
+ self._render_panel()
+ self.show_view(0)
+ self.status.emit(f"Settings of the run at {snapshot.taken.replace('T', ' ')} are back on "
+ f"{len(changed)} box{'es' if len(changed) != 1 else ''}; running")
+ self.run_from(changed)
def _tile_clicked(self, node_id: str) -> None:
"""A Summary tile: that box's Result, on the canvas."""
@@ -297,6 +340,18 @@ def _subtitle(self) -> str:
def refresh_title(self) -> None:
self.header.set_text(self.workflow.name, self._subtitle())
+ status = self._repro
+ self.header.set_badge(status.headline, status.tone,
+ "\n".join([status.headline] + ["· " + d for d in status.details]
+ + ["", "Click for the record."]),
+ self.show_record)
+
+ def refresh_reproducibility(self) -> None:
+ """Compare the record with the inputs on disk and the latest run (a run
+ finished, the analysis was saved)."""
+ self._repro = reproducibility.status(self.workflow, getattr(self.document, "record", None),
+ self.run, self.folder)
+ self.refresh_title()
# -- adding boxes: the picker ---------------------------------------------------------------
def picker(self) -> BoxPicker:
@@ -508,8 +563,13 @@ def _run_finished(self, outcome) -> None:
self.status.emit(outcome.summary())
else:
self.status.emit(f"The run stopped: {outcome}")
- self.refresh_title()
+ self.refresh_reproducibility()
self._render_panel()
+ if isinstance(outcome, Run) and not outcome.stopped and not self._has_queue:
+ if self.history.add(snapshots.take(self.workflow, outcome, self.history.latest)) \
+ and self.body.currentIndex() == 2:
+ self.runs.refresh()
+ self.ran.emit()
if self.body.currentIndex() == 1:
self.summary.refresh()
if self._has_queue or (isinstance(outcome, Run) and outcome.stopped):
@@ -816,7 +876,7 @@ def _tab_widget(self, node, spec, result: Result | None, index: int) -> QWidget:
if index == 1:
if result is None or result.status not in (DONE, FAILED):
return label("Runs first, then shows how it got there.", "muted", wrap=True)
- return how_widget(result.explanation, self)
+ return how_widget(result.explanation, self, result.value)
if index == 2:
return code_widget(spec, self)
settings = SettingsWidget(spec, node.settings, self.folder)
@@ -920,7 +980,11 @@ def show_record(self) -> None:
file_text.setReadOnly(True)
file_text.setFont(theme.mono_font(11))
from PySide6.QtWidgets import QApplication
+ status = self._repro
+ repro = label(f"{status.headline}. " + " ".join(status.details), "muted", wrap=True)
+ repro.setTextFormat(Qt.RichText)
dialog.setLayout(vbox(
+ label("REPRODUCIBILITY", "sectionLabel"), repro,
label("Everything needed to run this experiment again and get the same numbers. The Python and the "
"canvas are two views of the same workflow.", "muted", wrap=True),
label("AS PYTHON", "sectionLabel"), code, label("THE WORKFLOW FILE", "sectionLabel"), file_text,
@@ -934,10 +998,11 @@ def write_to(self, path: str, quiet: bool = False) -> None:
"""Save the workflow and its record to ``path`` (the window's Keep / Save As)."""
target = Path(path)
self.workflow.name = target.stem if target.stem else self.workflow.name
- self.document.record = records.save(self.workflow, target, self.run, self.folder or target.parent)
+ self.document.record = records.save(self.workflow, target, self.run, self.folder or target.parent,
+ history=self.history.to_dict())
self.document.path = str(target)
self.document.dirty = False
- self.refresh_title()
+ self.refresh_reproducibility()
if not quiet:
self.status.emit(f"Saved {target.name}")
self.saved.emit()
@@ -966,6 +1031,65 @@ def export(self) -> None:
path += ".cpnflow"
self.write_to(path)
+ def drawings(self) -> dict[str, str]:
+ """SVG per box whose result the canvas can draw: nets, maps, trees (for the report)."""
+ from ...flow.types import DFG, OCDFG, PetriNet, ProcessTree
+ from ..studio.graph_builders import dfg_specs, ocdfg_specs, petri_net_specs
+ from ..studio.graph_view import GraphView
+ out: dict[str, str] = {}
+ if self.run is None:
+ return out
+ for node in self.workflow.order():
+ result = self.run.result(node)
+ if result is None or result.status != DONE:
+ continue
+ value = result.value
+ view = GraphView()
+ if isinstance(value, PetriNet):
+ nodes, edges = petri_net_specs(value, show_place_names=len(value.places) <= 40)
+ positions = None
+ ids = {n.id for n in nodes}
+ if node.layout and ids <= set(node.layout):
+ positions = {k: v for k, v in node.layout.items() if k in ids}
+ view.graph.populate(nodes, edges, positions)
+ elif isinstance(value, DFG):
+ nodes, edges = dfg_specs(value, "frequency")
+ view.graph.populate(nodes, edges, layer_gap=64)
+ elif isinstance(value, OCDFG):
+ nodes, edges, _colours = ocdfg_specs(value)
+ view.graph.populate(nodes, edges, layer_gap=64)
+ elif isinstance(value, ProcessTree):
+ from ..studio.derivation_view import tree_specs
+ nodes, edges, positions = tree_specs(value)
+ view.graph.populate(nodes, edges, positions, layer_gap=40)
+ else:
+ continue
+ try:
+ out[node.id] = view.svg_text()
+ except Exception: # noqa: BLE001 - a drawing that fails is left out, the report still comes
+ continue
+ return out
+
+ def export_report(self) -> None:
+ """⋯ ▸ Export report…: the analysis as one HTML page."""
+ from ...flow import report as reports
+ from ..studio.widgets import suggested_path
+ path, _ = QFileDialog.getSaveFileName(self, "Export report", suggested_path(f"{self.workflow.name}.html"),
+ "Web page (*.html)")
+ if not path:
+ return
+ try:
+ reports.write_report(path, workflow=self.workflow, run=self.run, folder=self.folder,
+ drawings=self.drawings(), record=getattr(self.document, "record", None),
+ status=self._repro)
+ except OSError as error:
+ QMessageBox.warning(self, "Could not export", str(error))
+ return
+ self.status.emit(f"Exported the report to {Path(path).name}")
+ from PySide6.QtGui import QDesktopServices
+ from PySide6.QtCore import QUrl
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
+
def export_experiment(self) -> None:
from ..studio.widgets import suggested_path
path, _ = QFileDialog.getSaveFileName(self, "Export experiment", suggested_path(f"{self.workflow.name}.zip"),
diff --git a/openprocess/gui/flow/picker.py b/openprocess/gui/flow/picker.py
index 7d8eb88..198b084 100644
--- a/openprocess/gui/flow/picker.py
+++ b/openprocess/gui/flow/picker.py
@@ -29,7 +29,7 @@
#: How many of the folder's files the Input group lists before "… more".
FILE_LIMIT = 6
#: The input box for each kind of file in the folder (see workspace.file_kind).
-INPUT_BOXES = {"log": "open_log", "petri": "open_net", "cpn": "open_cpn",
+INPUT_BOXES = {"log": "open_log", "ocel": "open_ocel", "petri": "open_net", "cpn": "open_cpn",
"ts": "open_transition_system", "typed": "typed_log"}
@@ -223,7 +223,7 @@ def _add_files(self, rows: QVBoxLayout) -> None:
rows.addSpacing(6)
rows.addWidget(label("IN THIS FOLDER", "sectionLabel"))
names = [Path(relative).name for relative, _kind in files]
- icons = {"log": "log", "cpn": "cpn", "ts": "ts", "petri": "model"}
+ icons = {"log": "log", "ocel": "log", "cpn": "cpn", "ts": "ts", "petri": "model"}
for relative, kind in files:
name = Path(relative).name
text = display_name(name)
diff --git a/openprocess/gui/flow/runs.py b/openprocess/gui/flow/runs.py
new file mode 100644
index 0000000..6744e2e
--- /dev/null
+++ b/openprocess/gui/flow/runs.py
@@ -0,0 +1,130 @@
+"""The *Runs* view of an analysis: its snapshots, two of them compared.
+
+Every finished run is kept (the last twenty) with what changed before it
+and every box's key figure. Pick A and B and the boxes line up side by
+side, the ones whose result differs marked; *Use these settings* puts a
+run's settings back on the canvas, so a run can be returned to.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (QAbstractItemView, QButtonGroup, QHeaderView, QRadioButton, QTableWidget,
+ QTableWidgetItem, QVBoxLayout, QWidget)
+
+from ...flow.snapshots import Snapshot, compare
+from ..studio import style
+from ..studio.widgets import Card, button, hbox, label
+
+
+class RunsWidget(QWidget):
+ """Snapshots on top, the comparison of A and B under them; :meth:`refresh` rebuilds both."""
+
+ def __init__(self, page, parent=None) -> None:
+ super().__init__(parent)
+ self.page = page
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(14)
+ layout.addWidget(label(
+ "Every finished run is kept here (the last twenty), with what changed before it and every box's "
+ "key figure. Pick two, A and B, to see them side by side; Use these settings goes back to a run.",
+ "muted", wrap=True))
+ self.table = QTableWidget(0, 5)
+ self.table.setHorizontalHeaderLabels(["A", "B", "When", "What changed", "Boxes"])
+ self.table.verticalHeader().setVisible(False)
+ self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.table.setSelectionMode(QAbstractItemView.NoSelection)
+ self.table.setShowGrid(False)
+ header = self.table.horizontalHeader()
+ for column in (0, 1, 2, 4):
+ header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
+ header.setSectionResizeMode(3, QHeaderView.Stretch)
+ self.table.setMinimumHeight(140)
+ layout.addWidget(self.table)
+ self.group_a = QButtonGroup(self)
+ self.group_b = QButtonGroup(self)
+ self.group_a.idClicked.connect(lambda _i: self._compare())
+ self.group_b.idClicked.connect(lambda _i: self._compare())
+ self.comparison = Card("A and B, box by box", "Run the analysis: each finished run appears above.")
+ self.diff = QTableWidget(0, 4)
+ self.diff.setHorizontalHeaderLabels(["Box", "A", "B", "Changed"])
+ self.diff.verticalHeader().setVisible(False)
+ self.diff.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.diff.setSelectionMode(QAbstractItemView.NoSelection)
+ self.diff.setShowGrid(False)
+ diff_header = self.diff.horizontalHeader()
+ for column in (0, 1, 2):
+ diff_header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
+ diff_header.setSectionResizeMode(3, QHeaderView.Stretch)
+ self.diff.setMinimumHeight(120)
+ self.comparison.add(self.diff)
+ self.use_a = button("Use A's settings", lambda: self._use(self.a))
+ self.use_b = button("Use B's settings", lambda: self._use(self.b))
+ self.comparison.add(hbox(self.use_a, self.use_b, None))
+ layout.addWidget(self.comparison)
+ layout.addStretch(1)
+ self.snapshots: list[Snapshot] = []
+ self.a: Snapshot | None = None
+ self.b: Snapshot | None = None
+
+ def refresh(self) -> None:
+ """Rebuild from the page's history: newest first, A the run before the latest, B the latest."""
+ for group in (self.group_a, self.group_b):
+ for radio in group.buttons():
+ group.removeButton(radio)
+ self.snapshots = list(reversed(self.page.history.snapshots))
+ self.table.setRowCount(len(self.snapshots))
+ for row, snapshot in enumerate(self.snapshots):
+ for column, group in ((0, self.group_a), (1, self.group_b)):
+ radio = QRadioButton()
+ radio.setToolTip("A: the run to compare from" if column == 0 else "B: the run to compare to")
+ group.addButton(radio, row)
+ holder = QWidget()
+ holder.setLayout(hbox(radio, margins=(10, 0, 4, 0)))
+ self.table.setCellWidget(row, column, holder)
+ when = snapshot.taken.replace("T", " ")
+ self.table.setItem(row, 2, QTableWidgetItem(when))
+ self.table.setItem(row, 3, QTableWidgetItem(snapshot.label))
+ self.table.setItem(row, 4, QTableWidgetItem(f"{snapshot.done} of {len(snapshot.statuses)} done"))
+ if self.snapshots:
+ self.group_b.button(0).setChecked(True)
+ self.group_a.button(1 if len(self.snapshots) > 1 else 0).setChecked(True)
+ self._compare()
+
+ def _picked(self, group: QButtonGroup) -> Snapshot | None:
+ index = group.checkedId()
+ return self.snapshots[index] if 0 <= index < len(self.snapshots) else None
+
+ def _compare(self) -> None:
+ self.a, self.b = self._picked(self.group_a), self._picked(self.group_b)
+ self.diff.setRowCount(0)
+ if self.a is None or self.b is None:
+ self.comparison.caption_label.setText("Run the analysis: each finished run appears above.")
+ self.use_a.setEnabled(False)
+ self.use_b.setEnabled(False)
+ return
+ rows = compare(self.a, self.b)
+ changed = sum(1 for r in rows if r.changed)
+ self.comparison.caption_label.setText(
+ f"A: {self.a.taken.replace('T', ' ')} ({self.a.label}). B: {self.b.taken.replace('T', ' ')} "
+ f"({self.b.label}). {changed} of {len(rows)} boxes give a different result.")
+ self.diff.setRowCount(len(rows))
+ for index, row in enumerate(rows):
+ self.diff.setItem(index, 0, QTableWidgetItem(row.title))
+ self.diff.setItem(index, 1, QTableWidgetItem(row.a or "–"))
+ self.diff.setItem(index, 2, QTableWidgetItem(row.b or "–"))
+ what = ("; ".join(row.changes) if row.changes else ("yes" if row.changed else "no"))
+ item = QTableWidgetItem(what)
+ if row.changed:
+ item.setForeground(style.qc(style.STATUS["warning"]))
+ self.diff.setItem(index, 3, item)
+ self.use_a.setEnabled(True)
+ self.use_b.setEnabled(True)
+
+ def _use(self, snapshot: Snapshot | None) -> None:
+ if snapshot is not None:
+ self.page.apply_snapshot(snapshot)
+
+
+__all__ = ["RunsWidget"]
diff --git a/openprocess/gui/flow/stepper.py b/openprocess/gui/flow/stepper.py
new file mode 100644
index 0000000..afec8c2
--- /dev/null
+++ b/openprocess/gui/flow/stepper.py
@@ -0,0 +1,240 @@
+"""Step through an algorithm: the How tab, one moment at a time.
+
+The How tab lists everything a box reported: notes, the derivation's steps,
+intermediate values. For learning, seeing it all at once is too much and
+too little: too much text, and nothing moves. :class:`StepThrough` plays
+the same report as a sequence of *moments*, one step row or one note or one
+shown value each, with ◀ ▶ and a Play button, and keeps the box's result (a
+net or a process tree) drawn above, lighting up the places, transitions or
+activities the current step names and dimming the rest. So the eight steps
+of the α-algorithm become eight pictures: all activities, the start ones, the
+end ones, the candidate pairs, the maximal pairs, the places…
+
+Nothing is computed here. The moments are the box's own report, in order.
+"""
+
+from __future__ import annotations
+
+import re
+
+from PySide6.QtCore import Qt, QTimer, Signal
+from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
+
+from ...flow.explain import Explanation
+from ...flow.types import PetriNet, ProcessTree
+from .. import theme
+from ..studio import style
+from ..studio.graph_builders import petri_net_specs
+from ..studio.graph_view import GraphView, NodeSpec
+from ..studio.widgets import button, hbox, label
+
+#: How long Play rests on each moment.
+PLAY_MS = 1400
+
+
+def moments_of(explanation: Explanation) -> list[tuple]:
+ """The report as a flat sequence: ("note", text), ("step", title, content), ("show", label, value)."""
+ out: list[tuple] = []
+ for entry in explanation.entries:
+ if entry[0] == "note":
+ out.append(("note", entry[1]))
+ elif entry[0] == "steps":
+ for title, content in entry[1]:
+ out.append(("step", title, content))
+ elif entry[0] == "show":
+ out.append(("show", entry[1], entry[2]))
+ return out
+
+
+_PLACE = re.compile(r"[io]_L|p\(\{[^()]*\},\{[^()]*\}\)")
+_WORD = re.compile(r"[^\W\d_][\w\-']*", re.UNICODE)
+
+
+def named_in(moment: tuple) -> set[str]:
+ """The element names a moment mentions: words (activities, transitions)
+ and α-style place names such as ``p({a},{b})``."""
+ if moment[0] == "step":
+ text = f"{moment[1]} {moment[2]}"
+ elif moment[0] == "note":
+ text = moment[1]
+ else:
+ text = str(moment[1])
+ names = set(_WORD.findall(text))
+ names |= set(_PLACE.findall(text.replace(" ", "")))
+ return names
+
+
+class StepThrough(QWidget):
+ """The How tab as a player; :attr:`changed` carries the current moment's index."""
+
+ changed = Signal(int)
+
+ def __init__(self, explanation: Explanation, value=None, page=None, parent=None) -> None:
+ super().__init__(parent)
+ self.moments = moments_of(explanation)
+ self.value = value
+ self.page = page
+ self.index = 0
+ self.setFocusPolicy(Qt.StrongFocus)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+ # The controls.
+ self.back_button = button("◀", self.previous, tooltip="The step before (←)")
+ self.next_button = button("▶", self.next, tooltip="The next step (→)")
+ self.play_button = button("Play", self.toggle_play, tooltip="One step every second and a half")
+ self.counter = label("", "muted")
+ self.title = QLabel("")
+ self.title.setObjectName("cardTitle")
+ self.title.setWordWrap(True)
+ layout.addLayout(hbox(self.back_button, self.next_button, self.play_button, 10, self.counter, None))
+ layout.addWidget(self.title)
+ # The result, drawn once; its nodes light up per step.
+ self.view: GraphView | None = None
+ self._base: dict[str, NodeSpec] = {}
+ self._names: dict[str, str] = {} # node id -> the name a step would use
+ if isinstance(value, PetriNet):
+ nodes, edges = petri_net_specs(value, show_place_names=len(value.places) <= 40)
+ positions = None
+ if all(p.position for p in value.places.values()) and all(t.position for t in value.transitions.values()):
+ positions = {p.id: p.position for p in value.places.values()}
+ positions |= {t.id: t.position for t in value.transitions.values()}
+ self._names = {p.id: p.name for p in value.places.values()}
+ self._names |= {t.id: t.name for t in value.transitions.values()}
+ self._draw(nodes, edges, positions)
+ elif isinstance(value, ProcessTree):
+ from ..studio.derivation_view import tree_specs
+ nodes, edges, positions = tree_specs(value)
+ self._names = {n.id: n.text for n in nodes}
+ self._draw(nodes, edges, positions, layer_gap=40)
+ # The moments shown so far.
+ self.body = QWidget()
+ self.body.setObjectName("plain")
+ self.body_layout = QVBoxLayout(self.body)
+ self.body_layout.setContentsMargins(0, 0, 0, 0)
+ self.body_layout.setSpacing(8)
+ layout.addWidget(self.body)
+ layout.addStretch(1)
+ self.timer = QTimer(self)
+ self.timer.setInterval(PLAY_MS)
+ self.timer.timeout.connect(self._tick)
+ self.show_moment(0)
+
+ # -- drawing -----------------------------------------------------------------------------
+ def _draw(self, nodes, edges, positions, layer_gap: float = 48.0) -> None:
+ self.view = GraphView()
+ self.view.setMinimumHeight(220)
+ self.view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ self.view.graph.populate(nodes, edges, positions, layer_gap=layer_gap)
+ self._base = {n.id: n for n in nodes}
+ self.layout().addWidget(self.view)
+ QTimer.singleShot(0, self.view.fit)
+
+ def highlighted(self, index: int) -> set[str]:
+ """The ids of the drawn nodes the moment at ``index`` names."""
+ if not self._names or not (0 <= index < len(self.moments)):
+ return set()
+ names = named_in(self.moments[index])
+ compact = {name.replace(" ", "") for name in names}
+ return {node_id for node_id, name in self._names.items()
+ if name and (name in names or name.replace(" ", "") in compact)}
+
+ def _light(self, index: int) -> None:
+ if self.view is None:
+ return
+ t = style.tokens()
+ lit = self.highlighted(index)
+ for node_id, base in self._base.items():
+ spec = NodeSpec(**{**base.__dict__})
+ if lit:
+ if node_id in lit:
+ spec.fill = t.accent_soft
+ spec.stroke = t.accent
+ spec.emphasis = True
+ else:
+ spec.dimmed = True
+ self.view.graph.update_node(spec)
+
+ # -- stepping ----------------------------------------------------------------------------
+ def show_moment(self, index: int) -> None:
+ """Show the moments up to ``index`` and light up what the last one names."""
+ count = len(self.moments)
+ index = max(0, min(index, count - 1)) if count else 0
+ self.index = index
+ while self.body_layout.count():
+ item = self.body_layout.takeAt(0)
+ if item.widget() is not None:
+ item.widget().hide()
+ item.widget().deleteLater()
+ t = style.tokens()
+ for position, moment in enumerate(self.moments[:index + 1]):
+ current = position == index
+ self.body_layout.addWidget(self._render(moment, current, t))
+ moment = self.moments[index] if count else None
+ self.counter.setText(f"Step {index + 1} of {count}" if count else "Nothing to step through")
+ self.title.setText(moment[1] if moment and moment[0] == "step" else
+ ("Note" if moment and moment[0] == "note" else (moment[1] if moment else "")))
+ self.back_button.setEnabled(index > 0)
+ self.next_button.setEnabled(index < count - 1)
+ self._light(index)
+ self.changed.emit(index)
+
+ def _render(self, moment: tuple, current: bool, t) -> QWidget:
+ from .viewers import result_widget
+ if moment[0] == "note":
+ text = label("· " + moment[1], None if current else "muted", wrap=True, selectable=True)
+ return text
+ if moment[0] == "step":
+ title, content = moment[1], moment[2]
+ colour = t.text if current else t.text_muted
+ html = (f"
{title}
"
+ f"
{content}
")
+ row = QLabel(html)
+ row.setTextFormat(Qt.RichText)
+ row.setWordWrap(True)
+ row.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ return row
+ host = QWidget()
+ host.setObjectName("plain")
+ column = QVBoxLayout(host)
+ column.setContentsMargins(0, 0, 0, 0)
+ if moment[1]:
+ column.addWidget(label(str(moment[1]).upper(), "sectionLabel"))
+ column.addWidget(result_widget(moment[2], self.page))
+ return host
+
+ def next(self) -> None:
+ self.show_moment(self.index + 1)
+
+ def previous(self) -> None:
+ self.show_moment(self.index - 1)
+
+ def toggle_play(self) -> None:
+ if self.timer.isActive():
+ self.timer.stop()
+ self.play_button.setText("Play")
+ return
+ if self.index >= len(self.moments) - 1:
+ self.show_moment(0)
+ self.play_button.setText("Pause")
+ self.timer.start()
+
+ def _tick(self) -> None:
+ if self.index >= len(self.moments) - 1:
+ self.timer.stop()
+ self.play_button.setText("Play")
+ return
+ self.next()
+
+ def keyPressEvent(self, event) -> None: # noqa: N802
+ if event.key() in (Qt.Key_Right, Qt.Key_Space):
+ self.next()
+ elif event.key() == Qt.Key_Left:
+ self.previous()
+ else:
+ super().keyPressEvent(event)
+
+
+__all__ = ["StepThrough", "moments_of", "named_in", "PLAY_MS"]
diff --git a/openprocess/gui/flow/summary.py b/openprocess/gui/flow/summary.py
index 252e97f..b3cd46a 100644
--- a/openprocess/gui/flow/summary.py
+++ b/openprocess/gui/flow/summary.py
@@ -14,7 +14,8 @@
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import QFrame, QLabel, QSlider, QSizePolicy, QVBoxLayout, QWidget
-from ...flow.types import DFG, EventLog, Figure, PetriNet, Scores, Table, Text
+from ...flow.figures import tile_parts
+from ...flow.types import DFG, EventLog
from ..studio import style
from ..studio.graph_builders import dfg_specs
from ..studio.graph_view import GraphView
@@ -24,42 +25,6 @@
TILE_LIMIT = 24
-def _number(value) -> str:
- if isinstance(value, bool):
- return "yes" if value else "no"
- if isinstance(value, float):
- return f"{value:.2f}" if abs(value) < 1000 else f"{value:,.0f}"
- if isinstance(value, int):
- return f"{value:,}"
- return str(value)
-
-
-def tile_parts(value) -> tuple[str, str] | None:
- """(the big figure, the line under it) for a result, or None for a result
- that has no figure to show (a trace, a prediction model, …)."""
- if isinstance(value, Scores):
- items = [(k, v) for k, v in value.metrics.items() if not isinstance(v, (dict, list))]
- if not items:
- return None
- first, rest = items[0], items[1:4]
- return (f"{_number(first[1])} {first[0]}".strip(),
- " · ".join(f"{k} {_number(v)}" for k, v in rest) or value.model)
- if isinstance(value, Table):
- return f"{len(value.rows):,} × {len(value.columns)}", f"rows × columns · {value.name}"
- if isinstance(value, Figure):
- return "figure", value.caption or value.name
- if isinstance(value, EventLog):
- return f"{len(value):,} cases", f"{value.event_count:,} events"
- if isinstance(value, PetriNet):
- return f"{len(value.places)} · {len(value.transitions)}", "places · transitions"
- if isinstance(value, DFG):
- return f"{len(value.activities)} · {len(value.all_edges())}", "activities · paths"
- if isinstance(value, Text):
- first = str(value).strip().splitlines()[0] if str(value).strip() else ""
- return first[:40], value.name
- return None
-
-
class SummaryTile(QFrame):
"""One box's figure: the box (group and name), the figure, a line under it. Clickable."""
diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py
index c8d461c..f08420d 100644
--- a/openprocess/gui/flow/viewers.py
+++ b/openprocess/gui/flow/viewers.py
@@ -33,7 +33,7 @@
from ...flow.explain import Explanation
from ...flow.runner import Result
from ...flow.sweep import Sweep, is_sweep
-from ...flow.types import (AlignmentResult, CPNet, DFG, Dataset, EventLog, Figure, Footprint, PetriNet,
+from ...flow.types import (OCDFG, OCEL, AlignmentResult, CPNet, DFG, Dataset, EventLog, Figure, Footprint, PetriNet,
Predictions, Predictor, ProcessTree, Regions, ReplayResult, Scores, SimpleLog,
Table, Text, TransitionSystem)
from .. import theme
@@ -229,6 +229,32 @@ def tidied(_id: str, _x: float, _y: float, graph=view.graph, n=node) -> None:
formula = label(tree_formula(value), wrap=True)
formula.setTextFormat(Qt.RichText)
add(formula)
+ elif isinstance(value, OCEL):
+ counts = value.counts_by_type()
+ tiles = hbox(StatTile("Events", f"{len(value):,}"), StatTile("Activities", f"{len(value.activities):,}"),
+ StatTile("Objects", f"{len(value.objects):,}"),
+ StatTile("Object types", f"{len(value.object_types)}"), spacing=8)
+ tiles_host = QWidget()
+ tiles_host.setLayout(tiles)
+ add(tiles_host)
+ add(label("An object-centric log: every event names the objects it involves, by type. "
+ "Flatten on an object type for a classical log, or draw the object-centric map.",
+ "muted", wrap=True))
+ add(_table(["object type", "objects"], [[t, n] for t, n in counts.items()]))
+ add(_table(["activity", "events"], [[a, n] for a, n in value.activities.most_common()]))
+ elif isinstance(value, OCDFG):
+ from ..studio.graph_builders import ocdfg_specs
+ from ..studio.widgets import Legend, LegendSwatch
+ nodes, edges, colours = ocdfg_specs(value)
+ add(_graph(nodes, edges, layer_gap=64, height=300))
+ legend = Legend()
+ legend.set_items([(LegendSwatch("line", colour, 2.5), f"{object_type}: its own directly-follows "
+ f"counts") for object_type, colour in colours.items()])
+ add(legend)
+ add(label(value.summary() + ". Each object type is followed through its own events, so no case id "
+ "is invented.", "muted", wrap=True))
+ rows = [[t, a, b, n] for t in value.object_types for (a, b), n in value.edges[t].most_common()]
+ add(_table(["object type", "from", "to", "objects"], rows))
elif isinstance(value, DFG):
nodes, edges = dfg_specs(value)
add(_graph(nodes, edges, layer_gap=90))
@@ -345,7 +371,10 @@ def tidied(_id: str, _x: float, _y: float, graph=view.graph, n=node) -> None:
# ---------------------------------------------------------------------------
# How
# ---------------------------------------------------------------------------
-def how_widget(explanation: Explanation, page=None) -> QWidget:
+def how_widget(explanation: Explanation, page=None, value=None) -> QWidget:
+ """The box's report in full, with *Step through* to play it one moment at
+ a time over a drawing of ``value`` (the box's result)."""
+ from .stepper import StepThrough, moments_of
host = QWidget()
layout = QVBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0)
@@ -354,13 +383,43 @@ def how_widget(explanation: Explanation, page=None) -> QWidget:
layout.addWidget(label("This box has nothing to add: its result is all there is.", "muted", wrap=True))
return host
t = style.tokens()
+ full = QWidget()
+ full.setObjectName("plain")
+ full_layout = QVBoxLayout(full)
+ full_layout.setContentsMargins(0, 0, 0, 0)
+ full_layout.setSpacing(10)
+ if len(moments_of(explanation)) >= 2:
+ stepper: list = []
+
+ def toggle(_checked=False) -> None:
+ if stepper:
+ widget = stepper.pop()
+ widget.hide()
+ widget.setParent(None)
+ widget.deleteLater()
+ full.setVisible(True)
+ step_button.setText("Step through ▸")
+ return
+ widget = StepThrough(explanation, value, page)
+ stepper.append(widget)
+ layout.insertWidget(1, widget)
+ full.setVisible(False)
+ step_button.setText("◂ All at once")
+ widget.setFocus()
+ step_button = button("Step through ▸", toggle,
+ tooltip="Play the derivation one step at a time, with the result lighting up "
+ "what each step names")
+ step_button.setObjectName("stepThrough")
+ layout.addLayout(hbox(label(f"{len(moments_of(explanation))} moments: notes, steps and values, in the "
+ "order the box reported them.", "muted", wrap=True), step_button))
+ layout.addWidget(full)
for entry in explanation.entries:
if entry[0] == "note":
text = label("· " + entry[1], "muted" if not entry[1].startswith("Warning") else None, wrap=True,
selectable=True)
if entry[1].startswith("Warning"):
text.setStyleSheet(f"color: {style.STATUS['warning']};")
- layout.addWidget(text)
+ full_layout.addWidget(text)
elif entry[0] == "steps":
rows = []
for title, content in entry[1]:
@@ -372,12 +431,12 @@ def how_widget(explanation: Explanation, page=None) -> QWidget:
table.setTextFormat(Qt.RichText)
table.setWordWrap(True)
table.setTextInteractionFlags(Qt.TextSelectableByMouse)
- layout.addWidget(table)
+ full_layout.addWidget(table)
elif entry[0] == "show":
- caption, value = entry[1], entry[2]
+ caption = entry[1]
if caption:
- layout.addWidget(label(caption.upper(), "sectionLabel"))
- layout.addWidget(result_widget(value, page))
+ full_layout.addWidget(label(caption.upper(), "sectionLabel"))
+ full_layout.addWidget(result_widget(entry[2], page))
return host
@@ -553,7 +612,10 @@ def code_widget(spec: BoxSpec, page=None, compact: bool = True) -> QWidget:
whole = button("Whole file ⤢", lambda _checked=False, c=called: show_file(c, host.window()),
tooltip="The whole algorithm: the file this function lives in, in a window of its own")
whole.setObjectName("algorithmFile")
- algorithm_card.add(hbox(title, None, whole))
+ cite = button("Cite", lambda _checked=False, c=called: _cite(c, host.window()),
+ tooltip="BibTeX for the paper(s) this algorithm follows")
+ cite.setObjectName("algorithmCite")
+ algorithm_card.add(hbox(title, None, cite, whole))
follows = _follows(called)
if follows:
algorithm_card.add(follows)
@@ -565,6 +627,11 @@ def code_widget(spec: BoxSpec, page=None, compact: bool = True) -> QWidget:
return host
+def _cite(called: Called, parent=None):
+ from ..studio.cite import cite_module
+ return cite_module(called.module, called.name, parent)
+
+
def _extent(called: Called) -> str:
""""alpha_miner is 24 of alpha.py's 310 lines; the other 286 are the
algorithm's helpers" — so it is plain that the snippet is one function
@@ -771,7 +838,7 @@ def _kinds(self) -> tuple[str, ...]:
and so on (nothing for a box author to declare). Empty: any kind."""
kinds = []
for port in self.spec.outputs:
- for cls, kind in ((EventLog, "log"), (PetriNet, "petri"), (CPNet, "cpn"),
+ for cls, kind in ((EventLog, "log"), (OCEL, "ocel"), (PetriNet, "petri"), (CPNet, "cpn"),
(TransitionSystem, "ts")):
if port.type is cls:
kinds.append(kind)
@@ -798,6 +865,7 @@ def _files(self) -> list[str]:
def _dialog_filter(self) -> str:
"""The Choose… dialog's file types, matching :meth:`_files`."""
by_kind = {"log": "Event logs (*.xes *.xes.gz *.gz *.csv *.txt)",
+ "ocel": "Object-centric logs (*.jsonocel *.json)",
"petri": "Petri nets (*.pnml)", "cpn": "Coloured Petri nets (*.cpn)",
"ts": "Transition systems (*.txt)"}
kinds = self._kinds() or tuple(by_kind)
diff --git a/openprocess/gui/studio/app.py b/openprocess/gui/studio/app.py
index c91cd29..c5c0b2f 100644
--- a/openprocess/gui/studio/app.py
+++ b/openprocess/gui/studio/app.py
@@ -56,7 +56,7 @@
from pathlib import Path
from PySide6.QtCore import (
- QEvent, QFile, QFileSystemWatcher, QRect, QRectF, QSettings, QSize, Qt, QTimer, QUrl, Signal,
+ QEvent, QFile, QFileSystemWatcher, QPoint, QRect, QRectF, QSettings, QSize, Qt, QTimer, QUrl, Signal,
)
from PySide6.QtGui import (
QAction, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap, QShortcut,
@@ -916,6 +916,73 @@ def done(result) -> None:
run_in_background(lambda: PIP_RUNNER(command), done,
lambda message: done((1, str(message))))
+ # -- the command palette: ⌘K ------------------------------------------------------------
+ def palette_entries(self) -> list:
+ """Everything the palette can run: documents, the folder's files, boxes
+ for the current analysis, the spaces and Connections, every menu action."""
+ from .command_palette import Entry
+ from .documents import TransitionSystemDocument
+ entries: list[Entry] = []
+ kinds = {WorkflowDocument: "Analysis", CpnDocument: "Model", ModelDocument: "Model", LogDocument: "Log",
+ ComparisonDocument: "Comparison", TransitionSystemDocument: "Transition system"}
+ for document in self.documents:
+ kind = next((k for cls, k in kinds.items() if isinstance(document, cls)), "Document")
+ where = Path(document.path).name if document.path else "not saved"
+ entries.append(Entry(kind, document.name, where, lambda d=document: self._select_document(d),
+ keywords="open"))
+ if self._tree is not None:
+ open_paths = {self._key(d.path) for d in self.documents if d.path}
+ for folder in self._tree.walk():
+ for file in folder.files:
+ if self._key(file.path) in open_paths:
+ continue
+ entries.append(Entry("File", file.name, file.relative, lambda p=str(file.path): self.open_path(p),
+ keywords=file.kind, rank=1))
+ page = self.current_page()
+ library = self.library()
+ for group, specs in library.by_group().items():
+ for spec in specs:
+ if isinstance(page, WorkflowPage):
+ run = lambda s=spec, pg=page: pg.add_box_in_view(s.id)
+ detail = f"{group} · add to {page.workflow.name}"
+ else:
+ run = lambda s=spec: self.analysis_for("Untitled").add_box_in_view(s.id)
+ detail = f"{group} · add to a new analysis"
+ entries.append(Entry("Box", spec.name, detail, run, keywords=f"{spec.id} {group}", rank=2))
+ for space in SPACES:
+ entries.append(Entry("Space", LABELS[space], {MINE: "event logs and analyses", MODEL: "Petri nets and "
+ "coloured nets", LEARN: "exercises"}[space], lambda sp=space: self.set_space(sp)))
+ entries.append(Entry("Space", "Connections", "what flows in and out, and the tools that plug in",
+ self.show_connections))
+ for top in self.menuBar().actions():
+ menu = top.menu()
+ if menu is None:
+ continue
+ path = top.text().replace("&", "")
+ stack = [(menu, path)]
+ while stack:
+ current, trail = stack.pop(0)
+ for action in current.actions():
+ if action.isSeparator() or not action.text():
+ continue
+ if action.menu() is not None:
+ stack.append((action.menu(), f"{trail} ▸ {action.text().replace('&', '')}"))
+ continue
+ shortcut = action.shortcut().toString()
+ entries.append(Entry("Action", action.text().replace("&", ""),
+ trail + (f" · {shortcut}" if shortcut else ""),
+ lambda a=action: a.trigger(), rank=3))
+ return entries
+
+ def show_command_palette(self) -> None:
+ """⌘K: find and run anything by typing."""
+ from .command_palette import CommandPalette
+ if self.in_learn:
+ self.leave_learn()
+ self.command_palette = CommandPalette(self.palette_entries(), self)
+ centre = self.mapToGlobal(QPoint(self.width() // 2 - 280, 120))
+ self.command_palette.open_at(centre)
+
def show_connections(self) -> None:
"""The Connections view: what flows in and out, and the tools that plug in."""
if self.in_learn:
@@ -1284,6 +1351,7 @@ def _build_menus(self) -> None:
self._action(view_menu, LABELS[space], f"Ctrl+Alt+{index + 1}",
lambda _on=False, sp=space: self.set_space(sp))
self._action(view_menu, "Connections", None, self.show_connections)
+ self._action(view_menu, "Command Palette…", "Ctrl+K", self.show_command_palette)
view_menu.addSeparator()
self._action(view_menu, "Show Welcome Page", "Ctrl+1",
lambda: (self.in_learn and self.leave_learn(),
@@ -1325,6 +1393,7 @@ def _build_menus(self) -> None:
self._action(help_menu, "References", None, lambda: self.show_guide("references"))
self._action(help_menu, "Writing Exercise Packs", None,
lambda: self.show_guide("exercise-packs"))
+ self._action(help_menu, "Cite OpenProcess…", None, self.cite_app)
self._action(help_menu, "Check for Updates…", None,
lambda: self.check_for_updates(manual=True))
self._action(help_menu, f"About {APPLICATION_NAME}", None, self._about)
@@ -1347,6 +1416,11 @@ def _action(self, menu, text, shortcut, slot) -> QAction:
menu.addAction(action)
return action
+ def cite_app(self) -> None:
+ """Help ▸ Cite OpenProcess…: the app's BibTeX and a one-line citation."""
+ from .cite import cite_app
+ self.cite_dialog = cite_app(self)
+
def _definitions(self) -> None:
"""Every analysis property, defined mathematically (docs/definitions.md)."""
from .definition_view import show_reference
@@ -1442,6 +1516,7 @@ def _make_page(self, document) -> QWidget:
page.edit_requested.connect(lambda net, origin, doc=document: self.edit_petri_net(net, origin, doc))
page.keep_requested.connect(lambda doc=document: self.keep_workflow(doc))
page.edited.connect(lambda doc=document: (self._refresh_item(doc), self._schedule_autosave(doc)))
+ page.ran.connect(lambda doc=document: self._schedule_autosave(doc)) # the record follows the run
if self.workspace is not None:
page.custom_allowed = bool(self.workspace.settings().get("boxes_allowed"))
page._refresh_custom_bar()
@@ -2318,6 +2393,9 @@ def open_path(self, path: str, csv_mapping: dict | None = None) -> None:
if problems else ""), 10000)
elif lower.endswith(".txt") and file_kind(Path(path)) in ("log", "ts"):
self.add_document(self._read_text_file(path))
+ elif file_kind(Path(path)) == "ocel":
+ # An object-centric log has no page of its own: it is a box on an analysis.
+ self.boxes_for_files([path])
elif lower.endswith(".csv"):
if csv_mapping is not None: # reopening: reuse the saved mapping
mapping = ColumnMapping(**csv_mapping)
@@ -3136,7 +3214,7 @@ def _document_row(self, document) -> QTreeWidgetItem:
def _file_row(self, file, text: str) -> QTreeWidgetItem:
"""A file of the folder that is not open: lighter, opens with a click."""
- kind = {"log": "log", "cpn": "cpn", "ts": "ts", "workflow": "workflow"}.get(file.kind, "model")
+ kind = {"log": "log", "ocel": "log", "cpn": "cpn", "ts": "ts", "workflow": "workflow"}.get(file.kind, "model")
key = self._key(file.path)
if file.in_cloud:
text += " ☁"
@@ -3567,9 +3645,10 @@ def _autosave(self, document) -> None:
def _flush_autosaves(self, ids: list[int] | None = None) -> None:
"""Save now what autosave would save in a moment (switching, closing, quitting)."""
for document in list(self.documents):
+ timer = self._autosave_timers.get(document.id)
+ pending = timer is not None and timer.isActive() # a run waiting to be recorded
if (ids is None or document.id in ids) and getattr(document, "autosave", False) \
- and document.dirty:
- timer = self._autosave_timers.get(document.id)
+ and (document.dirty or pending):
if timer is not None:
timer.stop()
self._autosave(document)
diff --git a/openprocess/gui/studio/cite.py b/openprocess/gui/studio/cite.py
new file mode 100644
index 0000000..426346b
--- /dev/null
+++ b/openprocess/gui/studio/cite.py
@@ -0,0 +1,58 @@
+"""*Cite*: BibTeX for the app and for the works an algorithm follows, to copy."""
+
+from __future__ import annotations
+
+from PySide6.QtWidgets import QApplication, QDialog, QPlainTextEdit
+
+from ... import citation
+from .. import theme
+from .widgets import button, hbox, label, vbox
+
+
+def cite_dialog(title: str, intro: str, entries: list[citation.Entry], parent=None,
+ text_line: str = "") -> QDialog:
+ """A window with the BibTeX of ``entries`` (and a one-line citation when
+ given), each with a Copy button."""
+ dialog = QDialog(parent)
+ dialog.setWindowTitle(title)
+ dialog.resize(720, 560)
+ bibtex = "\n\n".join(entry.bibtex() for entry in entries)
+ code = QPlainTextEdit(bibtex)
+ code.setReadOnly(True)
+ code.setFont(theme.mono_font(11))
+ widgets = [label(intro, "muted", wrap=True)]
+ if text_line:
+ line = QPlainTextEdit(text_line)
+ line.setReadOnly(True)
+ line.setMaximumHeight(64)
+ widgets += [label("IN A SENTENCE", "sectionLabel"), line]
+ widgets += [label("BIBTEX", "sectionLabel"), code]
+ buttons = [button("Copy BibTeX", lambda: QApplication.clipboard().setText(bibtex), kind="primary")]
+ if text_line:
+ buttons.append(button("Copy the sentence", lambda: QApplication.clipboard().setText(text_line)))
+ widgets.append(hbox(*buttons, None, button("Close", dialog.close)))
+ dialog.setLayout(vbox(*widgets, margins=(16, 16, 16, 16)))
+ dialog.bibtex = bibtex
+ dialog.show()
+ return dialog
+
+
+def cite_app(parent=None) -> QDialog:
+ """Help ▸ Cite OpenProcess…"""
+ return cite_dialog(
+ "Cite OpenProcess",
+ "If OpenProcess helped with a course, a thesis or a paper, cite it like this. Each algorithm's Code "
+ "tab has a Cite button for the paper it follows.",
+ [citation.app_entry()], parent, citation.app_text())
+
+
+def cite_module(module: str, name: str, parent=None) -> QDialog:
+ """The Code tab's Cite: the works the function's module follows."""
+ entries = citation.entries_for_module(module)
+ intro = (f"The works that {name} follows, as BibTeX." if entries else
+ f"{name} has no reference on record: see Help ▸ References for the list, and cite OpenProcess "
+ "itself for the implementation.")
+ return cite_dialog(f"Cite: {name}", intro, entries or [citation.app_entry()], parent)
+
+
+__all__ = ["cite_dialog", "cite_app", "cite_module"]
diff --git a/openprocess/gui/studio/command_palette.py b/openprocess/gui/studio/command_palette.py
new file mode 100644
index 0000000..2c0f573
--- /dev/null
+++ b/openprocess/gui/studio/command_palette.py
@@ -0,0 +1,162 @@
+"""The command palette: one key, type, Enter.
+
+⌘K (Ctrl+K) opens a small window with a search field. Typing finds, all at
+once, the open analyses, models and logs, the folder's files, every box (to
+add to the current analysis), the three spaces and Connections, and every
+menu action by its name. Enter runs the first match; ↑ ↓ pick another. It
+fits the calm design better than more menus: nothing is added to the
+window, and anything in it is a few keystrokes away.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Callable
+
+from PySide6.QtCore import QPoint, Qt, Signal
+from PySide6.QtWidgets import (QFrame, QGraphicsDropShadowEffect, QLineEdit, QListWidget, QListWidgetItem,
+ QVBoxLayout, QWidget)
+from PySide6.QtGui import QColor
+
+from .. import theme
+from . import motion, style
+from .widgets import label, vbox
+
+SHADOW_MARGIN = 24
+LIMIT = 40
+#: The order kinds come in when nothing is typed, and their captions.
+KINDS = ("Analysis", "Model", "Log", "Comparison", "Transition system", "File", "Space", "Box", "Action")
+
+
+@dataclass
+class Entry:
+ kind: str
+ title: str
+ detail: str = ""
+ run: Callable[[], None] = lambda: None
+ keywords: str = ""
+ #: Lower is earlier when nothing is typed.
+ rank: int = field(default=0)
+
+ @property
+ def haystack(self) -> str:
+ return f"{self.title} {self.detail} {self.keywords} {self.kind}".lower()
+
+
+def match(entries: list[Entry], query: str, limit: int = LIMIT) -> list[Entry]:
+ """The entries every word of ``query`` is in, best first: a title that
+ starts with the query, then a title that contains it, then the rest."""
+ words = query.lower().split()
+ if not words:
+ return sorted(entries, key=lambda e: (KINDS.index(e.kind) if e.kind in KINDS else len(KINDS), e.rank,
+ e.title.lower()))[:limit]
+ scored = []
+ for entry in entries:
+ hay = entry.haystack
+ if not all(word in hay for word in words):
+ continue
+ title = entry.title.lower()
+ score = 0 if title.startswith(query.lower()) else 1 if query.lower() in title else \
+ 2 if all(word in title for word in words) else 3
+ scored.append((score, KINDS.index(entry.kind) if entry.kind in KINDS else len(KINDS), entry.rank,
+ title, entry))
+ scored.sort(key=lambda item: item[:4])
+ return [item[4] for item in scored[:limit]]
+
+
+class CommandPalette(QWidget):
+ """A popup: a search field and the matches; emits :attr:`ran` with the entry it ran."""
+
+ ran = Signal(object)
+
+ def __init__(self, entries: list[Entry], parent: QWidget | None = None) -> None:
+ super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.entries = entries
+ self.card = QFrame()
+ self.card.setObjectName("boxPicker")
+ shadow = QGraphicsDropShadowEffect(self.card)
+ shadow.setBlurRadius(SHADOW_MARGIN + 8)
+ shadow.setOffset(0, 6)
+ shadow.setColor(QColor(0, 0, 0, 110 if theme.is_dark() else 50))
+ self.card.setGraphicsEffect(shadow)
+ self.search = QLineEdit()
+ self.search.setObjectName("boxSearch")
+ self.search.setPlaceholderText("Type to find an analysis, a file, a box, a space or an action")
+ self.search.setClearButtonEnabled(True)
+ self.search.textChanged.connect(lambda _t: self.refill())
+ self.search.returnPressed.connect(self.run_current)
+ self.search.installEventFilter(self)
+ self.list = QListWidget()
+ self.list.setObjectName("paletteList")
+ self.list.setUniformItemSizes(True)
+ self.list.itemActivated.connect(lambda _item: self.run_current())
+ self.list.itemClicked.connect(lambda _item: self.run_current())
+ self.hint = label("↑ ↓ to pick · Enter to run · Esc to close", "muted")
+ self.card.setLayout(vbox(self.search, self.list, self.hint, spacing=10, margins=(14, 14, 14, 12)))
+ self.card.setMinimumWidth(560)
+ self.setLayout(vbox(self.card, margins=(SHADOW_MARGIN,) * 4))
+ self.matches: list[Entry] = []
+ self.refill()
+
+ def refill(self) -> None:
+ self.matches = match(self.entries, self.search.text())
+ self.list.clear()
+ t = style.tokens()
+ for entry in self.matches:
+ item = QListWidgetItem(f"{entry.title}")
+ detail = f"{entry.kind}" + (f" · {entry.detail}" if entry.detail else "")
+ item.setText(f"{entry.title} {detail}")
+ item.setToolTip(detail)
+ item.setForeground(QColor(t.text))
+ self.list.addItem(item)
+ if not self.matches:
+ item = QListWidgetItem("Nothing matches. Try another word.")
+ item.setFlags(Qt.NoItemFlags)
+ item.setForeground(QColor(t.text_muted))
+ self.list.addItem(item)
+ elif self.list.count():
+ self.list.setCurrentRow(0)
+ rows = max(1, min(len(self.matches) or 1, 12))
+ self.list.setFixedHeight(rows * 28 + 8)
+
+ def run_current(self) -> None:
+ row = self.list.currentRow()
+ if not self.matches:
+ return
+ entry = self.matches[row if 0 <= row < len(self.matches) else 0]
+ self.close()
+ entry.run()
+ self.ran.emit(entry)
+
+ def eventFilter(self, watched, event) -> bool: # noqa: N802
+ from PySide6.QtCore import QEvent
+ if watched is self.search and event.type() == QEvent.KeyPress:
+ key = event.key()
+ if key in (Qt.Key_Down, Qt.Key_Up):
+ row = self.list.currentRow() + (1 if key == Qt.Key_Down else -1)
+ if 0 <= row < self.list.count():
+ self.list.setCurrentRow(row)
+ return True
+ if key == Qt.Key_Escape:
+ self.close()
+ return True
+ return super().eventFilter(watched, event)
+
+ def open_at(self, point: QPoint) -> None:
+ """Show with the card's top-left at ``point`` (global), kept on screen."""
+ self.adjustSize()
+ screen = self.screen().availableGeometry() if self.screen() else None
+ hint = self.sizeHint()
+ width, height = hint.width() - 2 * SHADOW_MARGIN, hint.height() - 2 * SHADOW_MARGIN
+ x, y = point.x(), point.y()
+ if screen is not None:
+ x = max(screen.left(), min(x, screen.right() - width))
+ y = max(screen.top(), min(y, screen.bottom() - height))
+ self.move(x - SHADOW_MARGIN, y - SHADOW_MARGIN)
+ self.show()
+ motion.fade_in(self)
+ self.search.setFocus()
+
+
+__all__ = ["CommandPalette", "Entry", "match", "KINDS"]
diff --git a/openprocess/gui/studio/connections.py b/openprocess/gui/studio/connections.py
index dffb667..876d9c9 100644
--- a/openprocess/gui/studio/connections.py
+++ b/openprocess/gui/studio/connections.py
@@ -23,6 +23,7 @@
#: (title, what it is, the input boxes' function names)
INPUTS = [
("Event logs", "XES (also gzipped), CSV, or a .txt in the course notation", ("open_log",)),
+ ("Object-centric logs", "OCEL 2.0 JSON (.jsonocel), or the older JSON-OCEL", ("open_ocel", "flatten_ocel")),
("Petri nets", "PNML files, from any tool", ("open_net",)),
("Coloured nets", "CPN Tools .cpn files", ("open_cpn",)),
("Transition systems", "ts.txt, typed as s0 -a-> s1", ("open_transition_system",)),
diff --git a/openprocess/gui/studio/graph_builders.py b/openprocess/gui/studio/graph_builders.py
index 78cd771..5a0459a 100644
--- a/openprocess/gui/studio/graph_builders.py
+++ b/openprocess/gui/studio/graph_builders.py
@@ -81,6 +81,30 @@ def petri_net_specs(net: PetriNet, marking: Marking | None = None,
# ---------------------------------------------------------------------------
# Process maps
# ---------------------------------------------------------------------------
+def ocdfg_specs(graph) -> tuple[list[NodeSpec], list[EdgeSpec], dict[str, str]]:
+ """The object-centric map: the activities once, and one set of arrows per
+ object type in that type's colour, side by side where types share a path.
+ Returns the nodes, the edges and ``{object type: colour}`` for a legend."""
+ t = style.tokens()
+ colours = {object_type: style.categorical(index) for index, object_type in enumerate(graph.object_types)}
+ nodes = [NodeSpec(activity, "activity", text=activity, subtext=f"{count:,} events",
+ fill=t.surface, stroke=t.border,
+ tooltip=f"{activity}: {count:,} events\n" + "\n".join(
+ f"{object_type}: {graph.objects_at.get((activity, object_type), 0)} objects"
+ for object_type in graph.object_types))
+ for activity, count in graph.activities.most_common()]
+ edges: list[EdgeSpec] = []
+ busiest = max((n for counts in graph.edges.values() for n in counts.values()), default=1)
+ count_types = len(graph.object_types) or 1
+ for index, object_type in enumerate(graph.object_types):
+ offset = (index - (count_types - 1) / 2) * 12.0
+ for (a, b), n in graph.edges[object_type].items():
+ edges.append(EdgeSpec(a, b, text=str(n), width=1.2 + 2.6 * n / busiest, colour=colours[object_type],
+ tooltip=f"{object_type}: {n} object{'s' if n != 1 else ''} went {a} → {b}",
+ bend=offset if count_types > 1 else 0.0))
+ return nodes, edges, colours
+
+
def dfg_specs(dfg: DFG, mode: str = "frequency") -> tuple[list[NodeSpec], list[EdgeSpec]]:
"""Frequency mode: node fill = how often; edge width = how often.
Performance mode: edge labels are mean durations; the slowest edges are
diff --git a/openprocess/gui/studio/graph_view.py b/openprocess/gui/studio/graph_view.py
index e043fd1..40d36de 100644
--- a/openprocess/gui/studio/graph_view.py
+++ b/openprocess/gui/studio/graph_view.py
@@ -64,6 +64,7 @@ class EdgeSpec:
dashed: bool = False
tooltip: str = ""
arrow: bool = True # False: a plain line (a tree's "contains")
+ bend: float = 0.0 # sideways offset, so parallel edges (object types) stay apart
def _font(size: float = 11.5, bold: bool = False) -> QFont:
@@ -460,7 +461,11 @@ def populate(self, nodes: list[NodeSpec], edges: list[EdgeSpec],
if len(route) < 2:
route = [self.nodes[spec.source].pos(), self.nodes[spec.target].pos()]
item = EdgeItem(spec, self.nodes[spec.source], self.nodes[spec.target], route)
- if len(route) == 2 and (spec.target, spec.source) in pairs:
+ if spec.bend:
+ item.route = [self.nodes[spec.source].pos(), self.nodes[spec.target].pos()]
+ item.bend = spec.bend
+ item.rebuild()
+ elif len(route) == 2 and (spec.target, spec.source) in pairs:
item.bend = 16.0
item.rebuild()
self.addItem(item)
@@ -697,6 +702,25 @@ def export_png(self, path: str, scale: float = 2.0) -> None:
painter.end()
image.save(path)
+ def svg_text(self) -> str:
+ """The drawing as SVG text (for a report)."""
+ from PySide6.QtCore import QBuffer, QByteArray
+ from PySide6.QtSvg import QSvgGenerator
+ rect = self.graph.sceneRect()
+ data = QByteArray()
+ buffer = QBuffer(data)
+ buffer.open(QBuffer.WriteOnly)
+ generator = QSvgGenerator()
+ generator.setOutputDevice(buffer)
+ generator.setSize(rect.size().toSize())
+ generator.setViewBox(QRectF(0, 0, rect.width(), rect.height()))
+ painter = QPainter(generator)
+ self.graph.render(painter, QRectF(0, 0, rect.width(), rect.height()), rect)
+ painter.end()
+ buffer.close()
+ text = bytes(data).decode("utf-8", errors="replace")
+ return text[text.index("