diff --git a/CHANGELOG.md b/CHANGELOG.md index e71f39e..7e71778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ 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.11.2 + +- **Fixed: Describe log and Correlate failed with "No converter from + EventLog to pandas.DataFrame" whenever pandas was installed.** A converter + was registered under the class's defining module + (`pandas.core.frame.DataFrame`) and asked for by its public name + (`pandas.DataFrame`); the registry now resolves both to the class, in + either direction, and still imports nothing until a conversion is asked + for. The same fix covers pandas 3, which reports its classes under + `pandas` rather than `pandas.core.frame`. +- **What "reproducible" promises is written down**, in + [docs/workflows.md](docs/workflows.md#what-reproducible-promises): the + badge and `--check` mean fingerprints equal to the record's; three tiers + say how far that travels (same machine, same lockfile, another platform), + and what is and is not in the record. +- **docs/design.md: why OpenProcess is built the way it is.** The problem, + the claim, the three ideas and the decisions that follow from them, with + the open decisions (the `.cpnflow` name, what the 1.0 freeze covers). +- **A cross-check of the Inductive Miner against PM4Py**, + `tests/cross_check_inductive_miner.py`: both miners on the textbook logs + and 400 random ones, trees compared after sorting the commutative + operators, the IM guarantees checked on every log, and the precision of + both models wherever they differ. Baseline against PM4Py 2.7.23: the + guarantees hold on all 406 logs; 369 trees are identical; on 37 the + reference model is usually more precise, always for the same reason: after + a sequence cut, groups that are only ever skipped together are made + skippable separately. Not fixed yet; the script is there to show when it + is. + ## 0.11.1 - **A click no longer flings a box across the canvas.** Clicking a box diff --git a/README.md b/README.md index 50a339c..30df4d1 100644 --- a/README.md +++ b/README.md @@ -726,7 +726,10 @@ print(report.sound, report.findings) [**docs/how-it-works.md**](docs/how-it-works.md) covers the architecture, the CPN ML subset, binding search, timed nets, the state space, the file format -and the project layout. +and the project layout. [**docs/design.md**](docs/design.md) is the *why*: +the problem, the claim, the three ideas and the decisions that follow from +them. `tests/cross_check_inductive_miner.py` compares the built-in Inductive +Miner with PM4Py's on hundreds of logs and reports where they differ. ## Workflows diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..59438dc --- /dev/null +++ b/docs/design.md @@ -0,0 +1,136 @@ +# Why OpenProcess is built the way it is + +The reasoning behind the design, in one place, so that a decision can be +checked against it before it is made. The *what* is in the README; the +*how* is in [how-it-works.md](how-it-works.md) and +[workflows.md](workflows.md). This page is the *why*. + +## The problem + +A process-mining analysis, as it is usually done, cannot answer three +questions a reviewer, a supervisor or the analyst a year later will ask: + +1. **What exactly ran?** The model came from a plug-in, the map from + another tool, the table from a notebook. Which implementation, which + version, which settings? +2. **How did it get there?** A fitness of 0.87 is a number. The replay + behind it, the cuts the miner chose, the places the α-algorithm derived + from which pairs: not shown anywhere. +3. **Can I get the same numbers again?** Settings, seeds and input files + live in someone's memory. Re-running means reconstructing. + +The same gap shows up in teaching. A student sees the output of an +algorithm and is then asked, on an exam, to produce its derivation by hand. +The tool that could show the derivation shows only the result. + +## The claim + +**Code is mathematics, and an analysis should carry its own derivation.** +A proof in a paper shows its steps; a discovery algorithm is a procedure +whose model is its conclusion. A tool that gives only the conclusion is a +black box, however good the conclusion is. Everything in OpenProcess +follows from taking that seriously: what ran, how it got there, and how to +get the same numbers again are kept *with* the result, by the framework, +not by the discipline of the person using it. + +## Three ideas, and what each one buys + +1. **Every algorithm is a box, and every box is a plain Python function.** + The decorator reads the function's type hints and docstring; there is no + base class, no registration call, no plug-in descriptor. *Buys:* a + researcher's algorithm is on the canvas in the time it takes to add type + hints; the function still runs in a notebook or a test exactly as the + app runs it; and the Code tab can show the box's few lines and the + algorithm it calls, because the box *is* the code. +2. **Every analysis is a workflow file that can be re-run.** The `.cpnflow` + file records the boxes, settings and connections, plus fingerprints of + the inputs and the box code, every seed, the versions and the installed + packages. *Buys:* the badge, `--check` in CI, the experiment zip for a + paper, and the Runs tab; see "What reproducible promises" in + [workflows.md](workflows.md#what-reproducible-promises) for exactly + what is and is not promised. +3. **Every box shows how it got there.** A box reports notes, intermediate + values and derivation steps as it runs; a result object that keeps its + intermediate sets (`AlphaResult`, `InductiveResult`) is shown step by + step without any call. *Buys:* the How tab, Step through, and the same + object that an exam exercise is checked against. One source of truth for + the derivation, three consumers: the test suite, the app, and Learn. + +## Decisions, and the reasons + +- **Pure Python, no runtime dependencies for the engine.** The miners, the + conformance checks, the Petri net analysis, the CPN ML interpreter and the + simulator are standard library. *Reason:* the same code must run in a + notebook, in CI, in a student's exercise checker and in the app; a + 150 MB GUI toolkit or an AGPL library in the import path would rule out + the first three. Qt is an extra (`openprocess[app]`); PM4Py is an extra + that adds boxes and is the reference the built-in miners are checked + against. +- **The framework has no Qt in it.** `openprocess.flow` works from a script, + a test and `openprocess run`; the app only draws it. *Reason:* nothing + shown in the GUI may be GUI-only, or the record would describe something + the command line cannot reproduce. +- **Thin boxes over the engines, not algorithms written in the boxes.** A + box in `openprocess/flow/boxes/` is a few lines around one engine call. + *Reason:* the Code tab shows the box and then the algorithm it calls; a + box that contained the algorithm would hide it in the wrapper, and the + engine would no longer be usable without the framework. +- **Sources from the code, not beside it.** `docs/references.md` and + `docs/definitions.md` are generated from `openprocess/references.py` and + `openprocess/mining/definitions.py`, and a test fails when they drift. + Each algorithm's module docstring cites the work it follows; the Cite + button reads the same list. *Reason:* a citation that lives only in a + document goes stale; one that lives in the code is checked with it. +- **Results keep their intermediate steps.** `AlphaResult` keeps `T_L`, + `T_I`, `T_O`, `X_L` and `Y_L`; `InductiveResult` keeps every cut and + fall-through. *Reason:* see idea 3. It also makes the exam exercises + possible: "give `Y_L` for this log" is checked against the miner's own + `Y_L`. +- **Nothing on a page computes behind the user's back.** The log page's + Discover tab used to run a miner on the spot, with no code to read and no + record; since 0.9.5 it hands the log to the canvas. Every computed figure + on a page links to the function that computed it. *Reason:* a number + with no How and no Code is exactly the black box the claim rules out. +- **Three spaces, one engine.** *Mine* (workflows), *Model* (nets drawn by + hand, the token game, the CPN simulator) and *Learn* (worksheets) are + views on the same objects. *Reason:* the net a miner discovers and the + net a student draws are the same class; the soundness check, the + definitions and the counterexample replay are written once. The spaces + exist so that each audience sees one set of rules, not so that anything is + built twice. +- **Folders, not projects.** The app opens a folder (a week of a course, a + paper's repository); files in it are the sidebar; the app and the folder + stay in step both ways. *Reason:* the record refers to files by path and + hash, and a paper's repository is already a folder. A project file of our + own would be one more thing to lose. +- **Teaching and research share one transparency mechanism.** Learn's + checks, concealment and exam mode are built on the same results and + viewers as Mine. *Reason:* a derivation that can be shown is a derivation + that can be checked, in both directions. + +## What this is not + +- Not a replacement for pandas, scikit-learn or PM4Py. The Science and + Predict boxes are examples of how any Python library plugs in; OpenProcess + is where their results meet a log and a model, and get recorded. +- Not a workflow engine for anything but process mining. The types are the + types of the field (logs, nets, trees, transition systems, replays, + scores); `typing.Any` is an escape hatch, not the design. +- Not a research result in itself. The algorithms follow published work and + are checked against a reference implementation + (`tests/cross_check_inductive_miner.py`); what is new is the combination: + workbench, record and derivation in one place. + +## Open decisions + +- **The workflow file's name.** `.cpnflow` and `"format": "cpnflow/1"` + predate the rename from CPNpy to OpenProcess. Changing them is a + compatibility decision to take with the 1.0 API freeze, with a reader that + accepts both. +- **What the 1.0 freeze covers.** The proposal: `@box` and what it reads + from a signature, the types in `openprocess.flow.types`, the `.cpnflow` + format and its record, and the `openprocess run` command line. Everything + in the GUI stays free to change. +- **Where heavy results live.** In memory and recomputed on open (what the + cache does today), or written next to the workflow so a heavy experiment + opens instantly. The record makes both reproducible. diff --git a/docs/workflows.md b/docs/workflows.md index 787a072..781f321 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -249,6 +249,37 @@ A `.cpnflow` file is JSON: - `requirements_lock(record)` is a `requirements.lock` from the record's environment. +### What "reproducible" promises + +The badge beside an analysis, `--check` and the record all mean one precise +thing: **re-running the workflow gives results whose fingerprints equal the +recorded ones.** A fingerprint is the SHA-256 of a result's content (a +PNML string for a net, the rows for a table, the numbers of a score), so +"reproduces" is "byte for byte the same", not "close enough". How far that +promise travels depends on what else is the same: + +| Tier | What is the same | What the record gives you | What can still differ | +|---|---|---|---| +| **1. Same machine** | the folder, the installed packages, the OpenProcess version | the badge: *Reproducible*, or exactly which input, box file, seed or version changed since the record (`differences`) | nothing, for the built-in boxes: the engines are pure Python and every random choice is seeded | +| **2. Same environment, another machine** | `requirements.lock` installed (from the export, or `--lock`), the inputs from the zip (their hashes are checked), the same Python minor version | `openprocess run --check` passes or names the box whose result differs | a box of your own that depends on something outside the record: the clock, the file system's order, an unpinned download | +| **3. Another platform or version** | the same workflow and inputs, but another OS, Python or package version | the same: a pass is a pass; a failure names the box | floating-point results of numeric boxes (SciPy's tests, matplotlib's figures as SVG, a model trained with another BLAS) can differ in the last digits, and so can their fingerprints; the built-in miners and checks are integer and set arithmetic and do not | + +So a passing `--check` on the author's machine is tier 1; the claim a paper +can make is tier 2, and that is what the experiment zip is for. For tier 3, +compare the *values* the report shows rather than the fingerprints: a +fitness of 0.8712 on one platform and 0.8712 on another is the same result +even when an SVG of the plot is not. + +What is in the record, and what is not: + +- **In:** the SHA-256 of every input file and every box file in `boxes/`; + every seed; the OpenProcess, Python and package versions; the fingerprint + of every result; the platform. +- **Not in:** the code of OpenProcess's own boxes and the libraries they + call (their *versions* are; that is what the lock pins), the hardware, and + anything a box reads that was not declared as a `Path` setting (such a box + is reproducible only by the discipline of its author). + ### Headless, and in CI ```bash diff --git a/openprocess/flow/convert.py b/openprocess/flow/convert.py index 41441bc..f3dcc94 100644 --- a/openprocess/flow/convert.py +++ b/openprocess/flow/convert.py @@ -15,6 +15,7 @@ class and the target (a class, or a dotted name). from __future__ import annotations import importlib +import importlib.util from typing import Any, Callable from ..mining.log import KEY_NAME, KEY_RESOURCE, KEY_TIME, EventLog, SimpleLog @@ -25,11 +26,49 @@ class and the target (a class, or a dotted name). def _name(target) -> str: + """The name a type is registered under: ``module.qualname`` for a class, + a dotted string as given. Nothing is imported here, so registering a + converter for ``"pandas.core.frame.DataFrame"`` costs nothing when pandas + is absent, and importing this module never imports pandas.""" if isinstance(target, str): return target return f"{target.__module__}.{target.__qualname__}" +def _resolve(dotted: str): + """The class a dotted name points at, or None when it cannot be found. + + Tries the longest module prefix first (``a.b.c.D`` as module ``a.b.c``, + attribute ``D``), then shorter ones, so both ``pandas.DataFrame`` and + ``pandas.core.frame.DataFrame`` resolve to the same class. A name whose + top-level package is not installed resolves to None without importing + anything. + """ + parts = dotted.split(".") + if len(parts) < 2 or importlib.util.find_spec(parts[0]) is None: + return None + for split in range(len(parts) - 1, 0, -1): + try: + obj = importlib.import_module(".".join(parts[:split])) + except ImportError: + continue + for attribute in parts[split:]: + obj = getattr(obj, attribute, None) + if obj is None: + break + else: + if isinstance(obj, type): + return obj + return None + + +def _canonical(name: str) -> str: + """``name`` as the class it points at is defined (``module.qualname``), + or ``name`` itself when it points at nothing importable.""" + resolved = _resolve(name) + return name if resolved is None else _name(resolved) + + def register(source, target, function: Callable) -> None: """``function(value) -> converted`` for a value of class ``source``.""" _converters[(_name(source), _name(target))] = function @@ -39,20 +78,49 @@ def _class_names(value) -> list[str]: return [f"{cls.__module__}.{cls.__qualname__}" for cls in type(value).__mro__] +def _find(value: Any, target) -> Callable | None: + """The registered converter from ``value``'s class (or a base class) to + ``target``, or None. + + A class may be named by where it is imported from (``"pandas.DataFrame"``) + or where it is defined (``"pandas.core.frame.DataFrame"``, which pandas + 2 reports as the class's module, while pandas 3 reports ``"pandas"``). + Both the source and the target are matched by resolving the names to the + class. Only registered names in the same top-level package as the value + or the target are resolved, so looking up a pandas conversion imports + pandas and nothing else, and only once a conversion is asked for. + """ + wanted = _name(target) + wanted_names = {wanted, _canonical(wanted)} + target_package = wanted.split(".")[0] + value_names = set(_class_names(value)) + value_package = type(value).__module__.split(".")[0] + + def matches(registered: str, names: set[str], package: str) -> bool: + if registered in names: + return True + return registered.split(".")[0] == package and _canonical(registered) in names + + for (source, registered), function in _converters.items(): + if matches(source, value_names, value_package) and \ + matches(registered, wanted_names, target_package): + return function + return None + + def can_convert(value: Any, target) -> bool: - return any((name, _name(target)) in _converters for name in _class_names(value)) + return _name(target) in _class_names(value) or _find(value, target) is not None def convert(value: Any, target): """Convert ``value`` to ``target`` (a class or a dotted name).""" wanted = _name(target) - for name in _class_names(value): - if name == wanted: - return value - function = _converters.get((name, wanted)) - if function is not None: - return function(value) - raise TypeError(f"No converter from {type(value).__name__} to {wanted}") + if wanted in _class_names(value): + return value + function = _find(value, target) + if function is None: + raise TypeError(f"No converter from {type(value).__name__} to {wanted}") + return function(value) def converters() -> list[tuple[str, str]]: diff --git a/tests/cross_check_inductive_miner.py b/tests/cross_check_inductive_miner.py new file mode 100644 index 0000000..9f3086f --- /dev/null +++ b/tests/cross_check_inductive_miner.py @@ -0,0 +1,184 @@ +"""Cross-check OpenProcess's Inductive Miner against PM4Py's, log by log. + +Not a test (pytest does not collect it: no ``test_`` prefix) but a report, +because the two implementations are *allowed* to differ: the Inductive Miner +guarantees a sound model that fits the log, not a unique one. What this +script reports is how often they differ, on which logs, and whether the +guarantees hold on the OpenProcess side regardless. Run it after touching +``openprocess/mining/discovery/inductive.py``:: + + pip install pm4py # AGPL-3.0; a reference, not a dependency + python tests/cross_check_inductive_miner.py [seed] [random logs] + +What it does, for the six textbook logs and (by default) 400 random ones: + +1. runs both miners with no noise filtering; +2. canonicalises both process trees, sorting the children of the commutative + operators × and ∧ so that ``×(a, b)`` and ``×(b, a)`` compare equal; +3. checks the two IM guarantees on the OpenProcess result: the net is sound + and token replay gives fitness 1; +4. for every differing pair, compares the ETC precision (PM4Py's + token-based precision) of the two models on the same log, so a difference + can be read as "ours is less precise", "equal" or "more precise". + +Baseline, OpenProcess 0.11.1 against PM4Py 2.7.23, seed 2026, 400 random logs: +406 logs, guarantees hold on 406, identical trees on 369 (all six textbook logs +among them), 37 differ, of which 30 less precise, 4 equal, 3 more precise. +Every one of the 37 is the same choice: after a sequence cut, groups that are +always skipped *together* (``, ``: ``a`` and ``c`` never appear +without each other) are modelled as skippable *separately*, +``→(…, ×(a,τ), ×(c,τ))``, where the reference gives ``→(…, ×(→(a,c),τ))``. +""" + +from __future__ import annotations + +import os +import random +import sys +import tempfile +import warnings +from collections import Counter +from pathlib import Path + +# The repository root, so the script runs from a checkout without installing. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from openprocess.mining.analysis import check_soundness # noqa: E402 +from openprocess.mining.conformance.token_replay import token_replay # noqa: E402 +from openprocess.mining.discovery.inductive import inductive_miner # noqa: E402 +from openprocess.mining.log import SimpleLog, parse_simple_log # noqa: E402 +from openprocess.mining.pnml import pnml_string # noqa: E402 + +TEXTBOOK = [ + "[^3, ^2, ]", + "[^3, ^4, ^2, , " + "^2, ]", + "[^45, ^42, ^38, ^22]", + "[, , ]", + "[, , ]", + "[, ]", +] + + +# --------------------------------------------------------------------------- +# Canonical tree strings, so trees from the two libraries can be compared +# --------------------------------------------------------------------------- +def canonical_openprocess(tree) -> str: + """``->(a,X(b,tau))`` for an OpenProcess ProcessTree.""" + if tree.operator is None or not tree.children: + return "tau" if tree.is_tau else str(tree.label) + symbol = {"→": "->", "×": "X", "∧": "+", "↺": "*"}[tree.operator.value] + kids = [canonical_openprocess(child) for child in tree.children] + if symbol in ("X", "+"): + kids = sorted(kids) + return f"{symbol}({','.join(kids)})" + + +def canonical_pm4py(tree) -> str: + """The same string for a PM4Py ProcessTree.""" + from pm4py.objects.process_tree.obj import Operator + if tree.operator is None: + return "tau" if tree.label is None else tree.label + symbol = {Operator.SEQUENCE: "->", Operator.XOR: "X", + Operator.PARALLEL: "+", Operator.LOOP: "*"}[tree.operator] + kids = [canonical_pm4py(child) for child in tree.children] + if symbol in ("X", "+"): + kids = sorted(kids) + return f"{symbol}({','.join(kids)})" + + +# --------------------------------------------------------------------------- +# Logs: as OpenProcess's SimpleLog (a Counter of traces) and as a PM4Py frame +# --------------------------------------------------------------------------- +def to_dataframe(log: SimpleLog): + import pandas as pd + rows, case = [], 0 + for trace, count in log.items(): + for _ in range(count): + case += 1 + for position, activity in enumerate(trace): + rows.append({"case:concept:name": f"c{case}", "concept:name": activity, + "time:timestamp": pd.Timestamp("2026-01-01") + + pd.Timedelta(seconds=position)}) + return pd.DataFrame(rows) + + +def notation(log: SimpleLog) -> str: + return "[" + ", ".join(f"<{','.join(t)}>^{n}" for t, n in log.items()) + "]" + + +def random_logs(seed: int, count: int) -> list[SimpleLog]: + """Small logs over 2–5 activities, 2–8 variants of 1–6 events each.""" + rng = random.Random(seed) + logs = [] + for _ in range(count): + alphabet = "abcdef"[: rng.randint(2, 5)] + log: SimpleLog = Counter() + for _ in range(rng.randint(2, 8)): + trace = tuple(rng.choice(alphabet) for _ in range(rng.randint(1, 6))) + log[trace] += rng.randint(1, 4) + logs.append(log) + return logs + + +# --------------------------------------------------------------------------- +# Precision of both models on the same log, through PM4Py +# --------------------------------------------------------------------------- +def precisions(log: SimpleLog, ours, theirs_tree) -> tuple[float, float]: + import pm4py + from pm4py.objects.conversion.process_tree import converter as tree_to_net + frame = to_dataframe(log) + handle, path = tempfile.mkstemp(suffix=".pnml") + os.close(handle) + try: + Path(path).write_text(pnml_string(ours), encoding="utf-8") + net, initial, final = pm4py.read_pnml(path) + finally: + os.unlink(path) + from pm4py.algo.evaluation.precision import algorithm as precision_algorithm + quiet = {"show_progress_bar": False} + mine = precision_algorithm.apply(frame, net, initial, final, parameters=quiet) + net, initial, final = tree_to_net.apply(theirs_tree) + reference = precision_algorithm.apply(frame, net, initial, final, parameters=quiet) + return mine, reference + + +def main(argv: list[str]) -> int: + warnings.filterwarnings("ignore") + import pm4py + seed = int(argv[1]) if len(argv) > 1 else 2026 + count = int(argv[2]) if len(argv) > 2 else 400 + logs = [("textbook", parse_simple_log(s)) for s in TEXTBOOK] + logs += [("random", log) for log in random_logs(seed, count)] + + same, different, broken = 0, [], [] + for kind, log in logs: + result = inductive_miner(log) + reference = pm4py.discover_process_tree_inductive(to_dataframe(log), noise_threshold=0.0) + ours, theirs = canonical_openprocess(result.tree), canonical_pm4py(reference) + sound = check_soundness(result.net).sound + fitness = token_replay(result.net, log).fitness + if not sound or abs(fitness - 1.0) > 1e-9: + broken.append((notation(log), sound, fitness)) + if ours == theirs: + same += 1 + else: + different.append((kind, log, ours, theirs, *precisions(log, result.net, reference))) + + less = sum(1 for d in different if d[4] < d[5] - 1e-9) + more = sum(1 for d in different if d[4] > d[5] + 1e-9) + print(f"OpenProcess Inductive Miner vs PM4Py {pm4py.__version__}, seed {seed}") + print(f"{len(logs)} logs: identical trees {same}, different {len(different)} " + f"(ours less precise {less}, equal {len(different) - less - more}, more precise {more})") + print(f"IM guarantees (sound, fitness 1) broken on {len(broken)} logs") + for entry in broken[:10]: + print(" ", entry) + print("\nDifferences (first 15):") + for kind, log, ours, theirs, mine, ref in different[:15]: + print(f"- [{kind}] {notation(log)}\n OpenProcess {ours} precision {mine:.3f}" + f"\n PM4Py {theirs} precision {ref:.3f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/test_flow.py b/tests/test_flow.py index e9fff13..369b514 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -498,6 +498,26 @@ def test_bootstrap_fitness_has_an_interval(): assert table.samples and len(table.samples) == 30 and table.rows[0][0] == "mean" +def test_converter_matches_a_class_by_either_of_its_names(): + """A target may be named by where it is imported from or where it is + defined; the registry matches both. json.JSONDecoder lives in + json.decoder, so this needs no optional package.""" + from openprocess.flow import convert as c + assert c._canonical("json.JSONDecoder") == "json.decoder.JSONDecoder" + assert c._canonical("json.decoder.JSONDecoder") == "json.decoder.JSONDecoder" + assert c._canonical("no_such_package.Thing") == "no_such_package.Thing" # nothing imported + c.register(Table, "json.decoder.JSONDecoder", lambda table: len(table.rows)) + try: + table = Table("t", ["a"], [[1], [2]]) + assert c.can_convert(table, "json.JSONDecoder") + assert c.convert(table, "json.JSONDecoder") == 2 + import json + assert c.convert(table, json.JSONDecoder) == 2 + assert not c.can_convert(table, "json.JSONEncoder") + finally: + del c._converters[(c._name(Table), "json.decoder.JSONDecoder")] + + @pytest.mark.skipif(not has("pandas"), reason="pandas not installed") def test_describe_log_through_the_converter(): from openprocess.flow.boxes.science import describe_log