Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .zenodo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"title": "OpenProcess: process mining, Petri nets and workflows in one open app",
"upload_type": "software",
"description": "OpenProcess is a desktop app for process mining and Petri net modelling in which every algorithm is a function whose code and source paper are one click away, and every analysis keeps a record of its inputs, versions and seeds so that its numbers can be checked again. Mine (event logs, discovery, conformance), Model (Petri nets and coloured nets) and Learn (exercise packs) in one window.",
"creators": [
{"name": "Struthers, Sam"}
],
"license": "mit",
"access_right": "open",
"keywords": [
"process mining",
"Petri nets",
"workflow nets",
"coloured Petri nets",
"conformance checking",
"event logs",
"teaching",
"reproducible research"
],
"related_identifiers": [
{"identifier": "https://github.com/s4mstruthers/openprocess", "relation": "isSupplementTo", "scheme": "url"}
]
}
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ and each [release on GitHub](https://github.com/s4mstruthers/openprocess/release
uses it as its description. Write for the people using the app: what they will
notice, not how it was done. Versions before 0.7.0 were released as CPNpy.

## 0.10.6

- **The reproducibility badge is a quiet pill** beside the title: a soft tint
with a dot and a few words ("● Reproducible", "● 2 changes since",
"● Not recorded"), the full sentence on hover. It no longer pushes the
title aside, and when the header is short of room it keeps only its dot.
- **Install a tool from the downloaded app too.** Connections ▸ *Install…*
now works in the app you download, not only from a Python install: the
app carries pip, installs into a folder of your own (on a Mac,
Library/Application Support/OpenProcess/packages), and the boxes that need
the package come alive without a restart. Ready-built packages only, so
PM4Py still needs a Python install.
- **A page opened from a box is a view, not a file.** *Open as log ›* no
longer writes a file into the folder the moment it opens. The page has
*Keep in folder* for when you want one; ✕ closes it without a question and
leaves nothing in the sidebar. Files you opened yourself still close to a
lighter row, and deleting stays an explicit Move to Bin.
- The status chips on cards (*not installed*, *installing…*, *ready*, the
Learn chips) are pills everywhere. They draw themselves now: a styled
label's corners depended on when the style polished it, and the downloaded
app showed square chips beside round ones.
- **A column setting is a dropdown.** Plot's *x* and *y* list the columns of
the table connected to the box, once it has run; a name can still be
typed. A box author writes `x: Column = ""`, nothing more: the new
`Column` type is a plain string to the function.
- A box with nothing plugged in (Plot, just added to the canvas) waits with
*Connect scores or tables* instead of failing with a traceback.
- The downloaded app carries the whole standard library, so a package
installed from Connections can import what it needs (matplotlib wanted
`unittest`, and Plot failed after the install).
- Releases are archived on Zenodo; the DOI goes into `CITATION.cff` and the
app's citations once the first archive is there.

## 0.10.5

- **Object-centric event logs.** *Open object-centric log* reads OCEL 2.0
Expand Down
8 changes: 4 additions & 4 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
cff-version: 1.2.0
message: If you use OpenProcess in teaching or research, please cite it as below.
message: "If you use OpenProcess in teaching or research, please cite it as below."
type: software
title: OpenProcess: process mining, Petri nets and workflows in one open app
version: 0.10.5
date-released: 2026-10-10
title: "OpenProcess: process mining, Petri nets and workflows in one open app"
version: 0.10.6
date-released: 2026-10-11
repository-code: https://github.com/s4mstruthers/openprocess
url: https://github.com/s4mstruthers/openprocess
license: MIT
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,11 @@ extra) and *Predict the next activity*. Or start empty.
draws what flows in, OpenProcess and its boxes, what flows out, and the
optional tools (pandas, numpy, scipy, matplotlib, …) with whether each is
installed, how to install it and the boxes it brings. All of it is read
from the box library.
from the box library. *Install…* works in the downloaded app too: it carries
pip and installs into a folder of your own (on a Mac,
`~/Library/Application Support/OpenProcess/packages`), and the boxes that
need the package come alive without a restart. PM4Py needs a Python
install (`pip install pm4py`), as parts of it are built from source.
- **Motion** is kept short: a space switch, the side panel and Canvas ↔
Summary settle in a fifth of a second, and a box pulses once when it
finishes. `OPENPROCESS_NO_MOTION=1` turns it off.
Expand Down Expand Up @@ -930,6 +934,16 @@ Plainly stated:
Next up: several instances of a subpage, CPN monitors, and signed apps that
open without a security prompt.

## Cite

If OpenProcess helped with a course, a thesis or a paper, cite it. *Help ▸
Cite OpenProcess…* in the app gives BibTeX and a one-line citation, and
each algorithm's Code tab has *Cite* for the paper it follows. GitHub's
*Cite this repository* reads [CITATION.cff](CITATION.cff). Releases are
archived on Zenodo, which gives each version a DOI; the all-versions DOI
goes into `CITATION.cff` (`doi:`) once the first release is archived, and
the app's citations pick it up from there.

## Licence

MIT. See [LICENSE](LICENSE).
1 change: 1 addition & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ What the decorator reads, and nothing else:
| the return type | the output; a `@dataclass` whose fields are OpenProcess types gives several outputs (one per field) |
| `int`, `float`, `bool`, `str` with a default | a setting, the default as its value |
| `Literal["a", "b"]` | a setting that is a choice |
| `Column` | a setting that is a column of a table connected to the box: in the app, a list of its columns to pick from (a name can still be typed) |
| `Path` | a setting that is a file in the workflow's folder |
| the docstring | the help text; a `name: text` line in it is the help of that setting |
| `@box(name=…, group=…)` | the name on the canvas and the group under *+ Add box* (default: the function's name, and *Other*, which is listed under *Show all*) |
Expand Down
2 changes: 1 addition & 1 deletion openprocess/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
#: The one place the version number is written (pyproject.toml, the update
#: check and packaging/build.py read it from here). A release is tagged
#: "v" + this, e.g. v0.2.0.
__version__ = "0.10.5"
__version__ = "0.10.6"

__all__ = [
"Arc", "BindingElement", "CPNet", "Marking", "Multiset", "Page", "Place",
Expand Down
47 changes: 37 additions & 10 deletions openprocess/citation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import datetime as _dt
import re
from dataclasses import dataclass, field
from pathlib import Path

from . import references as _references

Expand All @@ -27,6 +28,23 @@ def app_version() -> str:
return __version__


def concept_doi() -> str:
"""The DOI Zenodo gives the software (all versions), from ``CITATION.cff``
at the top of the repository or installed beside the package; "" until
the first release is archived."""
import re
for candidate in (Path(__file__).resolve().parents[1] / "CITATION.cff",
Path(__file__).resolve().parent / "CITATION.cff"):
try:
text = candidate.read_text(encoding="utf-8")
except OSError:
continue
found = re.search(r"^doi:\s*(\S+)\s*$", text, re.M)
if found:
return found.group(1).strip("'\"")
return ""


# ---------------------------------------------------------------------------
# One entry
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -137,45 +155,54 @@ def entries_for_module(module: str) -> list[Entry]:
# ---------------------------------------------------------------------------
# The app itself
# ---------------------------------------------------------------------------
def app_entry(version: str | None = None, year: int | None = None) -> Entry:
def app_entry(version: str | None = None, year: int | None = None, doi: str | None = None) -> Entry:
version = version or app_version()
year = year or _dt.date.today().year
doi = concept_doi() if doi is None else doi
return Entry("software", "openprocess", {
"author": " and ".join(f"{last}, {first}" for last, first in APP_AUTHORS),
"title": APP_TITLE,
"version": version,
"year": str(year),
"doi": doi,
"url": APP_URL,
"note": "Every algorithm shows its code and the paper it follows; every analysis keeps a record "
"that runs again",
})


def app_bibtex(version: str | None = None, year: int | None = None) -> str:
return app_entry(version, year).bibtex()
def app_bibtex(version: str | None = None, year: int | None = None, doi: str | None = None) -> str:
return app_entry(version, year, doi).bibtex()


def app_text(version: str | None = None, year: int | None = None) -> str:
def app_text(version: str | None = None, year: int | None = None, doi: str | None = None) -> str:
"""The citation in one line, as a reference list would have it."""
version = version or app_version()
year = year or _dt.date.today().year
doi = concept_doi() if doi is None else doi
authors = ", ".join(f"{first} {last}" for last, first in APP_AUTHORS)
return f"{authors}. {APP_TITLE} (version {version}) [software], {year}. {APP_URL}"
where = f"https://doi.org/{doi}" if doi else APP_URL
return f"{authors}. {APP_TITLE} (version {version}) [software], {year}. {where}"


def cff_text(version: str | None = None, released: _dt.date | None = None) -> str:
"""The contents of ``CITATION.cff`` (Citation File Format 1.2.0) for ``version``."""
def cff_text(version: str | None = None, released: _dt.date | None = None, doi: str = "") -> str:
"""The contents of ``CITATION.cff`` (Citation File Format 1.2.0) for
``version``; ``doi`` is Zenodo's concept DOI once the first release is archived."""
version = version or app_version()
released = released or _dt.date.today()
authors = "\n".join(f" - family-names: {last}\n given-names: {first}" for last, first in APP_AUTHORS)
def quoted(text: str) -> str: # a YAML string: a colon in the title would otherwise break it
return '"' + text.replace('\\', '\\\\').replace('"', '\\"') + '"'

return (
"cff-version: 1.2.0\n"
"message: If you use OpenProcess in teaching or research, please cite it as below.\n"
f"message: {quoted('If you use OpenProcess in teaching or research, please cite it as below.')}\n"
"type: software\n"
f"title: {APP_TITLE}\n"
f"title: {quoted(APP_TITLE)}\n"
f"version: {version}\n"
f"date-released: {released.isoformat()}\n"
f"repository-code: {APP_URL}\n"
+ (f"doi: {doi}\n" if doi else "")
+ f"repository-code: {APP_URL}\n"
f"url: {APP_URL}\n"
"license: MIT\n"
"keywords:\n - process mining\n - Petri nets\n - workflow nets\n - conformance checking\n"
Expand Down
3 changes: 2 additions & 1 deletion openprocess/flow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ def my_miner(log: EventLog, threshold: float = 0.5) -> PetriNet:
from .record import check, differences, load, save
from .runner import Cache, Result, Run, Runner
from .sweep import Sweep
from .types import (AlignmentResult, Any, CPNet, DFG, Dataset, EventLog, Figure, Footprint, Marking,
from .types import (Column, AlignmentResult, Any, CPNet, DFG, Dataset, EventLog, Figure, Footprint, Marking,
PetriNet, Predictions, Predictor, ProcessTree, Regions, ReplayResult, Scores,
SimpleLog, Table, Text, TransitionSystem, register_type)
from .workflow import Edge, Group, Node, Workflow, WorkflowError, to_python, workflow

__all__ = [
"Column",
"AlignmentResult", "Any", "Box", "BoxError", "BoxSpec", "CPNet", "Cache", "DFG", "Dataset", "Edge",
"EventLog", "Explanation", "Figure", "Footprint", "Group", "Library", "Marking", "Node", "PetriNet",
"Port", "Predictions", "Predictor", "ProcessTree", "Regions", "ReplayResult", "Result", "Run",
Expand Down
9 changes: 6 additions & 3 deletions openprocess/flow/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def learned_states(log: EventLog, clusters: int = 8, seed: int = 0) -> Transitio
the return type the output; a dataclass of typed fields gives several
``int``, ``float``, ``bool``, ``str`` with a default a setting, with the default as its value
``Literal["a", "b"]`` a setting that is a choice
``Column`` a setting that is a column of a connected table
``Path`` a setting that is a file in the folder
the docstring the help text
``inspect.getsource`` the Code tab
Expand All @@ -44,7 +45,7 @@ def learned_states(log: EventLog, clusters: int = 8, seed: int = 0) -> Transitio
from pathlib import Path
from typing import Any, Callable, Literal, Union, get_args, get_origin, get_type_hints

from .types import TypeInfo, is_known, type_info
from .types import Column, TypeInfo, is_known, type_info

SETTING_TYPES = (int, float, bool, str)

Expand Down Expand Up @@ -106,7 +107,7 @@ class Setting:
"""A parameter that is not an input: shown as a control in the side panel."""

name: str
kind: str # "int", "float", "bool", "str", "choice", "path"
kind: str # "int", "float", "bool", "str", "choice", "path", "column"
default: Any
choices: tuple = ()
help: str = ""
Expand Down Expand Up @@ -273,12 +274,14 @@ def _setting_from(name: str, annotation, default, has_default: bool, doc_hints:
kind = "int"
elif annotation is float:
kind = "float"
elif annotation is Column:
kind = "column"
elif annotation is str:
kind = "str"
else:
raise BoxError(
f"parameter {name!r} has type {_type_text(annotation)}, which is neither a OpenProcess "
f"type (an input) nor int, float, bool, str, Literal[...] or Path (a setting)")
f"type (an input) nor int, float, bool, str, Literal[...], Column or Path (a setting)")
if not has_default:
raise BoxError(f"setting {name!r} needs a default value (e.g. {name}: {kind} = ...)")
if kind == "float" and isinstance(default, int) and not isinstance(default, bool):
Expand Down
4 changes: 2 additions & 2 deletions openprocess/flow/boxes/science.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from ...mining.petrinet import PetriNet
from ..box import box
from ..convert import convert
from ..types import Figure, Scores, Table
from ..types import Column, Figure, Scores, Table


@box(name="Describe log", group="Science", needs="pandas")
Expand Down Expand Up @@ -109,7 +109,7 @@ def correlate(table: Table) -> Table:

@box(name="Plot", group="Science", needs="matplotlib")
def plot(scores: list[Scores] = (), tables: list[Table] = (),
x: str = "", y: str = "") -> Figure:
x: Column = "", y: Column = "") -> Figure:
"""Bars per model for scores, histograms for bootstrap tables, or a line
of column *y* against column *x* of a table (a sweep). The figure is a
matplotlib figure, converted to SVG; its code is this box's code.
Expand Down
9 changes: 9 additions & 0 deletions openprocess/flow/reproducibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ class Status:
def tone(self) -> str:
return {"reproduces": "good", "changed": "warning", "differs": "critical"}.get(self.state, "muted")

@property
def short(self) -> str:
"""Two or three words for a badge; :attr:`headline` is the sentence."""
if self.state == "changed":
count = len(self.details)
return f"{count} change{'s' if count != 1 else ''} since"
return {"unrecorded": "Not recorded", "reproduces": "Reproducible", "differs": "Differs from the record",
"unchecked": "Recorded, not run"}.get(self.state, self.headline)


def status(workflow: Workflow, record: Record | None, run: Run | None, folder=None) -> Status:
"""The analysis's reproducibility, from its record and its latest run."""
Expand Down
29 changes: 28 additions & 1 deletion openprocess/flow/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@
"waiting", "running", "done", "failed", "idle", "blocked", "skipped")


def columns_connected(workflow: Workflow, run: "Run | None", node: Node) -> list[str]:
"""The columns of the tables connected to ``node``, as of ``run``: what a
``Column`` setting lists. Empty until the boxes before it have run."""
if run is None:
return []
from .types import Table
out: list[str] = []
for sources in workflow.inputs_of(node).values():
for source_id, output in sources:
result = run.result(source_id)
if result is None or result.status != DONE:
continue
value = result.values.get(output, result.value)
for item in (value if isinstance(value, (list, tuple)) else [value]):
if isinstance(item, Table):
out.extend(column for column in item.columns if column not in out)
return out


@dataclass
class Result:
"""What one box produced in one run (one per sweep variant)."""
Expand Down Expand Up @@ -270,6 +289,14 @@ def _run_node(self, workflow: Workflow, node: Node, run: Run, variant: int,
values.append(self._fit(value, port))
keys.append(upstream.key + ":" + output)
inputs[port.name] = values if port.many else values[0]
# Nothing plugged in at all (every input optional, as Plot's are): the box
# would run on nothing and fail. Wait instead, and say what to connect.
if spec.inputs and not any(fed[port.name] for port in spec.inputs):
labels = [port.label or port.name for port in spec.inputs]
result.status = IDLE
result.message = "Connect " + (labels[0] if len(labels) == 1 else
", ".join(labels[:-1]) + " or " + labels[-1])
return result
# A file not chosen yet (a box just added from the list): wait and say
# so, rather than run and fail with a traceback about None.
for setting in spec.settings:
Expand Down Expand Up @@ -376,5 +403,5 @@ def run_in_process(spec: BoxSpec, inputs: dict, settings: dict, timeout: float |
return outcome[1], outcome[2]


__all__ = ["BLOCKED", "Cache", "DONE", "FAILED", "IDLE", "RUNNING", "Result", "Run", "Runner",
__all__ = ["columns_connected", "BLOCKED", "Cache", "DONE", "FAILED", "IDLE", "RUNNING", "Result", "Run", "Runner",
"WAITING", "run_in_process", "settings_key"]
12 changes: 12 additions & 0 deletions openprocess/flow/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ def to_csv(self, path) -> None:
writer.writerows(self.rows)



class Column(str):
"""The name of a column of a table connected to the box.

A setting typed ``Column`` (``x: Column = ""``) is a plain string to the
function. In the app it is a list of the connected table's columns to
pick from, once the box before it has run; a name can still be typed.
It is a setting, not an input: the table is the input.
"""


@dataclass
class Figure:
"""A picture: SVG text, or PNG bytes, as a box made it."""
Expand Down Expand Up @@ -286,6 +297,7 @@ def is_known(cls) -> bool:
register_type(_cls, _key, _name, _colour)

__all__ = [
"Column",
"AlignmentResult", "Any", "CPNet", "DFG", "Dataset", "EventLog", "Figure", "Footprint", "OCDFG", "OCEL",
"Marking", "PetriNet", "Predictions", "Predictor", "ProcessTree", "Regions", "ReplayResult",
"Scores", "SimpleLog", "Table", "Text", "TransitionSystem", "TypeInfo", "TYPES",
Expand Down
Loading
Loading