diff --git a/pm4py/__init__.py b/pm4py/__init__.py index fa0cc2b3a..4852e1207 100644 --- a/pm4py/__init__.py +++ b/pm4py/__init__.py @@ -201,6 +201,10 @@ ocel_o2o_enrichment, ocel_e2o_lifecycle_enrichment, cluster_equivalent_ocel, + ocel_drill_down, + ocel_roll_up, + ocel_unfold, + ocel_fold, ) from pm4py.vis import ( view_petri_net, diff --git a/pm4py/algo/transformation/ocel/olap/__init__.py b/pm4py/algo/transformation/ocel/olap/__init__.py new file mode 100644 index 000000000..b7ca2075e --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap import drill_down, roll_up, unfold, fold diff --git a/pm4py/algo/transformation/ocel/olap/drill_down/__init__.py b/pm4py/algo/transformation/ocel/olap/drill_down/__init__.py new file mode 100644 index 000000000..7e2b131c3 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/drill_down/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.drill_down import algorithm diff --git a/pm4py/algo/transformation/ocel/olap/drill_down/algorithm.py b/pm4py/algo/transformation/ocel/olap/drill_down/algorithm.py new file mode 100644 index 000000000..9e8970888 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/drill_down/algorithm.py @@ -0,0 +1,66 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from enum import Enum +from typing import Any, Dict, Optional + +from pm4py.algo.transformation.ocel.olap.drill_down.variants import classic +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Variants(Enum): + CLASSIC = classic + + +def apply( + ocel: OCEL, + variant=Variants.CLASSIC, + parameters: Optional[Dict[Any, Any]] = None, +) -> OCEL: + """ + Applies the drill-down OLAP operation on an object-centric event log, + splitting the target object type into tuple-style sub-types based on + the values of a given object attribute. + + Reference: Khayatbashi, Miri, Jalali. "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations." CAiSE Forum 2025 + (arXiv:2412.00393). + + Parameters + -------------- + ocel + Object-centric event log + variant + Variant of the algorithm to be used, possible values: + - Variants.CLASSIC + parameters + Variant-specific parameters + + Returns + -------------- + new_ocel + A new object-centric event log with the drilled-down object type. + """ + if parameters is None: + parameters = {} + + return exec_utils.get_variant(variant).apply(ocel, parameters=parameters) diff --git a/pm4py/algo/transformation/ocel/olap/drill_down/variants/__init__.py b/pm4py/algo/transformation/ocel/olap/drill_down/variants/__init__.py new file mode 100644 index 000000000..6778740a4 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/drill_down/variants/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.drill_down.variants import classic diff --git a/pm4py/algo/transformation/ocel/olap/drill_down/variants/classic.py b/pm4py/algo/transformation/ocel/olap/drill_down/variants/classic.py new file mode 100644 index 000000000..35d19129e --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/drill_down/variants/classic.py @@ -0,0 +1,129 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +import copy as copy_mod +from enum import Enum +from typing import Any, Callable, Dict, Optional + +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Parameters(Enum): + OBJECT_TYPE = "object_type" + OBJECT_ATTRIBUTE = "object_attribute" + TUPLE_FORMAT = "tuple_format" + + +def _default_format(parent: str, value: str) -> str: + return "(" + parent + ", " + value + ")" + + +def apply(ocel: OCEL, parameters: Optional[Dict[Any, Any]] = None) -> OCEL: + """ + Drill-down: splits a target object type into sub-types based on the + values of a given object attribute. + + For every object whose type equals ``object_type`` and whose + ``object_attribute`` value is defined, the type is rewritten to + ``tuple_format(object_type, value)``. Objects whose attribute value is + undefined (NaN / empty) keep their original type. + + Parameters + -------------- + ocel + Object-centric event log + parameters + - Parameters.OBJECT_TYPE: name of the object type to split (required) + - Parameters.OBJECT_ATTRIBUTE: name of the object attribute column + to split by (required; must be a column of ``ocel.objects``) + - Parameters.TUPLE_FORMAT: optional callable + ``(parent: str, value: str) -> str`` used to build the new type + name; defaults to ``"(parent, value)"``. + + Notes + -------------- + This implementation reads attribute values from ``ocel.objects`` only; + time-varying attribute values in ``ocel.object_changes`` are not used + to drive the split. + + Returns a new OCEL; the input log is left untouched. + """ + if parameters is None: + parameters = {} + + ot = exec_utils.get_param_value(Parameters.OBJECT_TYPE, parameters, None) + oa = exec_utils.get_param_value( + Parameters.OBJECT_ATTRIBUTE, parameters, None + ) + fmt: Callable[[str, str], str] = exec_utils.get_param_value( + Parameters.TUPLE_FORMAT, parameters, _default_format + ) + + if ot is None: + raise ValueError("drill_down requires Parameters.OBJECT_TYPE") + if oa is None: + raise ValueError("drill_down requires Parameters.OBJECT_ATTRIBUTE") + + result = copy_mod.deepcopy(ocel) + type_col = result.object_type_column + oid_col = result.object_id_column + + if ot not in set(result.objects[type_col].unique()): + raise ValueError( + "object type %r not found in ocel.objects[%r]" % (ot, type_col) + ) + if oa not in result.objects.columns: + raise ValueError( + "object attribute %r is not a column of ocel.objects" % (oa,) + ) + + attr = result.objects[oa] + type_match = result.objects[type_col] == ot + attr_defined = attr.notna() & (attr.astype(str) != "") + mask = type_match & attr_defined + + if mask.any(): + new_types = [ + fmt(ot, str(v)) for v in result.objects.loc[mask, oa].tolist() + ] + result.objects.loc[mask, type_col] = new_types + + oid_to_type = dict( + zip(result.objects[oid_col], result.objects[type_col]) + ) + result.relations[type_col] = ( + result.relations[oid_col] + .map(oid_to_type) + .fillna(result.relations[type_col]) + ) + + if ( + type_col in result.object_changes.columns + and len(result.object_changes) > 0 + ): + result.object_changes[type_col] = ( + result.object_changes[oid_col] + .map(oid_to_type) + .fillna(result.object_changes[type_col]) + ) + + return result diff --git a/pm4py/algo/transformation/ocel/olap/fold/__init__.py b/pm4py/algo/transformation/ocel/olap/fold/__init__.py new file mode 100644 index 000000000..b803f2288 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/fold/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.fold import algorithm diff --git a/pm4py/algo/transformation/ocel/olap/fold/algorithm.py b/pm4py/algo/transformation/ocel/olap/fold/algorithm.py new file mode 100644 index 000000000..42b14cccb --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/fold/algorithm.py @@ -0,0 +1,66 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from enum import Enum +from typing import Any, Dict, Optional + +from pm4py.algo.transformation.ocel.olap.fold.variants import classic +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Variants(Enum): + CLASSIC = classic + + +def apply( + ocel: OCEL, + variant=Variants.CLASSIC, + parameters: Optional[Dict[Any, Any]] = None, +) -> OCEL: + """ + Applies the fold OLAP operation on an object-centric event log, + collapsing a previously unfolded tuple-style event type back to its + parent event type. Inverse of unfold. + + Reference: Khayatbashi, Miri, Jalali. "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations." CAiSE Forum 2025 + (arXiv:2412.00393). + + Parameters + -------------- + ocel + Object-centric event log + variant + Variant of the algorithm to be used, possible values: + - Variants.CLASSIC + parameters + Variant-specific parameters + + Returns + -------------- + new_ocel + A new object-centric event log with the folded event type. + """ + if parameters is None: + parameters = {} + + return exec_utils.get_variant(variant).apply(ocel, parameters=parameters) diff --git a/pm4py/algo/transformation/ocel/olap/fold/variants/__init__.py b/pm4py/algo/transformation/ocel/olap/fold/variants/__init__.py new file mode 100644 index 000000000..0af9bf1dc --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/fold/variants/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.fold.variants import classic diff --git a/pm4py/algo/transformation/ocel/olap/fold/variants/classic.py b/pm4py/algo/transformation/ocel/olap/fold/variants/classic.py new file mode 100644 index 000000000..4615010a1 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/fold/variants/classic.py @@ -0,0 +1,86 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +import copy as copy_mod +from enum import Enum +from typing import Any, Callable, Dict, Optional + +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Parameters(Enum): + EVENT_TYPE = "event_type" + OBJECT_TYPE = "object_type" + TUPLE_FORMAT = "tuple_format" + + +def _default_format(parent: str, value: str) -> str: + return "(" + parent + ", " + value + ")" + + +def apply(ocel: OCEL, parameters: Optional[Dict[Any, Any]] = None) -> OCEL: + """ + Fold: the inverse of unfold. Replaces the unfolded activity + ``tuple_format(event_type, object_type)`` back with ``event_type`` in + both ``ocel.events`` and ``ocel.relations``. + + Parameters + -------------- + ocel + Object-centric event log + parameters + - Parameters.EVENT_TYPE: the parent event type to merge back to + (required) + - Parameters.OBJECT_TYPE: the object type used during the matching + unfold (required) + - Parameters.TUPLE_FORMAT: optional callable matching unfold's + encoding; default ``"(event_type, object_type)"``. + + Returns a new OCEL; the input log is left untouched. + """ + if parameters is None: + parameters = {} + + et = exec_utils.get_param_value(Parameters.EVENT_TYPE, parameters, None) + ot = exec_utils.get_param_value(Parameters.OBJECT_TYPE, parameters, None) + fmt: Callable[[str, str], str] = exec_utils.get_param_value( + Parameters.TUPLE_FORMAT, parameters, _default_format + ) + + if et is None: + raise ValueError("fold requires Parameters.EVENT_TYPE") + if ot is None: + raise ValueError("fold requires Parameters.OBJECT_TYPE") + + result = copy_mod.deepcopy(ocel) + act_col = result.event_activity + + folded_act = fmt(et, ot) + + ev_mask = result.events[act_col] == folded_act + if ev_mask.any(): + result.events.loc[ev_mask, act_col] = et + rel_mask = result.relations[act_col] == folded_act + if rel_mask.any(): + result.relations.loc[rel_mask, act_col] = et + + return result diff --git a/pm4py/algo/transformation/ocel/olap/roll_up/__init__.py b/pm4py/algo/transformation/ocel/olap/roll_up/__init__.py new file mode 100644 index 000000000..2a3feccf3 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/roll_up/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.roll_up import algorithm diff --git a/pm4py/algo/transformation/ocel/olap/roll_up/algorithm.py b/pm4py/algo/transformation/ocel/olap/roll_up/algorithm.py new file mode 100644 index 000000000..c92105e31 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/roll_up/algorithm.py @@ -0,0 +1,66 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from enum import Enum +from typing import Any, Dict, Optional + +from pm4py.algo.transformation.ocel.olap.roll_up.variants import classic +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Variants(Enum): + CLASSIC = classic + + +def apply( + ocel: OCEL, + variant=Variants.CLASSIC, + parameters: Optional[Dict[Any, Any]] = None, +) -> OCEL: + """ + Applies the roll-up OLAP operation on an object-centric event log, + collapsing tuple-style sub-types of a given parent object type back + to the parent type. Inverse of drill-down. + + Reference: Khayatbashi, Miri, Jalali. "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations." CAiSE Forum 2025 + (arXiv:2412.00393). + + Parameters + -------------- + ocel + Object-centric event log + variant + Variant of the algorithm to be used, possible values: + - Variants.CLASSIC + parameters + Variant-specific parameters + + Returns + -------------- + new_ocel + A new object-centric event log with the rolled-up object type. + """ + if parameters is None: + parameters = {} + + return exec_utils.get_variant(variant).apply(ocel, parameters=parameters) diff --git a/pm4py/algo/transformation/ocel/olap/roll_up/variants/__init__.py b/pm4py/algo/transformation/ocel/olap/roll_up/variants/__init__.py new file mode 100644 index 000000000..712852e4c --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/roll_up/variants/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.roll_up.variants import classic diff --git a/pm4py/algo/transformation/ocel/olap/roll_up/variants/classic.py b/pm4py/algo/transformation/ocel/olap/roll_up/variants/classic.py new file mode 100644 index 000000000..1a06bbd50 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/roll_up/variants/classic.py @@ -0,0 +1,117 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +import copy as copy_mod +from enum import Enum +from typing import Any, Callable, Dict, Optional + +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Parameters(Enum): + OBJECT_TYPE = "object_type" + OBJECT_ATTRIBUTE = "object_attribute" + TUPLE_MATCHER = "tuple_matcher" + + +def _default_matcher(type_value: Any, parent: str) -> bool: + """Recognizes the default ``"(parent, value)"`` tuple encoding.""" + if not isinstance(type_value, str): + return False + if type_value == parent: + return False + return type_value.startswith("(" + parent + ", ") and type_value.endswith( + ")" + ) + + +def apply(ocel: OCEL, parameters: Optional[Dict[Any, Any]] = None) -> OCEL: + """ + Roll-up: the inverse of drill-down. Collapses tuple-style sub-types of + a given parent object type back to the parent type. + + Parameters + -------------- + ocel + Object-centric event log + parameters + - Parameters.OBJECT_TYPE: name of the parent object type to merge + back to (required) + - Parameters.OBJECT_ATTRIBUTE: optional, accepted for API symmetry + with drill_down; if provided, must be a column of + ``ocel.objects``. Not semantically required because the tuple + type value already encodes the split. + - Parameters.TUPLE_MATCHER: optional callable + ``(type_value, parent) -> bool`` deciding whether ``type_value`` + is a sub-type of ``parent``. Default matches the + ``"(parent, value)"`` encoding produced by drill_down. + + Returns a new OCEL; the input log is left untouched. + """ + if parameters is None: + parameters = {} + + ot = exec_utils.get_param_value(Parameters.OBJECT_TYPE, parameters, None) + oa = exec_utils.get_param_value( + Parameters.OBJECT_ATTRIBUTE, parameters, None + ) + matcher: Callable[[Any, str], bool] = exec_utils.get_param_value( + Parameters.TUPLE_MATCHER, parameters, _default_matcher + ) + + if ot is None: + raise ValueError("roll_up requires Parameters.OBJECT_TYPE") + + result = copy_mod.deepcopy(ocel) + type_col = result.object_type_column + oid_col = result.object_id_column + + if oa is not None and oa not in result.objects.columns: + raise ValueError( + "object attribute %r is not a column of ocel.objects" % (oa,) + ) + + mask = result.objects[type_col].map(lambda t: matcher(t, ot)) + + if mask.any(): + result.objects.loc[mask, type_col] = ot + + oid_to_type = dict( + zip(result.objects[oid_col], result.objects[type_col]) + ) + result.relations[type_col] = ( + result.relations[oid_col] + .map(oid_to_type) + .fillna(result.relations[type_col]) + ) + + if ( + type_col in result.object_changes.columns + and len(result.object_changes) > 0 + ): + result.object_changes[type_col] = ( + result.object_changes[oid_col] + .map(oid_to_type) + .fillna(result.object_changes[type_col]) + ) + + return result diff --git a/pm4py/algo/transformation/ocel/olap/unfold/__init__.py b/pm4py/algo/transformation/ocel/olap/unfold/__init__.py new file mode 100644 index 000000000..47f0b4b72 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/unfold/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.unfold import algorithm diff --git a/pm4py/algo/transformation/ocel/olap/unfold/algorithm.py b/pm4py/algo/transformation/ocel/olap/unfold/algorithm.py new file mode 100644 index 000000000..bef5a24f9 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/unfold/algorithm.py @@ -0,0 +1,66 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from enum import Enum +from typing import Any, Dict, Optional + +from pm4py.algo.transformation.ocel.olap.unfold.variants import classic +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Variants(Enum): + CLASSIC = classic + + +def apply( + ocel: OCEL, + variant=Variants.CLASSIC, + parameters: Optional[Dict[Any, Any]] = None, +) -> OCEL: + """ + Applies the unfold OLAP operation on an object-centric event log, + splitting an event type into a tuple-style sub-type keyed by a related + object type (optionally filtered by a qualifier set). + + Reference: Khayatbashi, Miri, Jalali. "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations." CAiSE Forum 2025 + (arXiv:2412.00393). + + Parameters + -------------- + ocel + Object-centric event log + variant + Variant of the algorithm to be used, possible values: + - Variants.CLASSIC + parameters + Variant-specific parameters + + Returns + -------------- + new_ocel + A new object-centric event log with the unfolded event type. + """ + if parameters is None: + parameters = {} + + return exec_utils.get_variant(variant).apply(ocel, parameters=parameters) diff --git a/pm4py/algo/transformation/ocel/olap/unfold/variants/__init__.py b/pm4py/algo/transformation/ocel/olap/unfold/variants/__init__.py new file mode 100644 index 000000000..93ac05107 --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/unfold/variants/__init__.py @@ -0,0 +1,22 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +from pm4py.algo.transformation.ocel.olap.unfold.variants import classic diff --git a/pm4py/algo/transformation/ocel/olap/unfold/variants/classic.py b/pm4py/algo/transformation/ocel/olap/unfold/variants/classic.py new file mode 100644 index 000000000..9eb1f6cbd --- /dev/null +++ b/pm4py/algo/transformation/ocel/olap/unfold/variants/classic.py @@ -0,0 +1,107 @@ +''' +PM4Py – A Process Mining Library for Python +Copyright (C) 2026 Process Intelligence Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see this software project's root or +visit . + +Website: https://processintelligence.solutions +Contact: info@processintelligence.solutions +''' +import copy as copy_mod +from enum import Enum +from typing import Any, Callable, Dict, Iterable, Optional + +from pm4py.objects.ocel.obj import OCEL +from pm4py.util import exec_utils + + +class Parameters(Enum): + EVENT_TYPE = "event_type" + OBJECT_TYPE = "object_type" + QUALIFIERS = "qualifiers" + TUPLE_FORMAT = "tuple_format" + + +def _default_format(parent: str, value: str) -> str: + return "(" + parent + ", " + value + ")" + + +def apply(ocel: OCEL, parameters: Optional[Dict[Any, Any]] = None) -> OCEL: + """ + Unfold: splits an event type into a tuple-style sub-type keyed by a + related object type. + + For every event of type ``event_type`` that has at least one E2O + relation to an object of type ``object_type`` (optionally restricted + to qualifiers in ``qualifiers``), the event's activity is rewritten + to ``tuple_format(event_type, object_type)`` in both ``ocel.events`` + and ``ocel.relations``. + + Parameters + -------------- + ocel + Object-centric event log + parameters + - Parameters.EVENT_TYPE: the event type/activity to unfold (required) + - Parameters.OBJECT_TYPE: the related object type to unfold over + (required) + - Parameters.QUALIFIERS: optional iterable of qualifier values; + when provided, only E2O relations whose qualifier is in this + set contribute to the unfolding. Default: all qualifiers. + - Parameters.TUPLE_FORMAT: optional callable + ``(event_type, object_type) -> str`` producing the new activity + name. Must match fold's format to be reversible. Default: + ``"(event_type, object_type)"``. + + Returns a new OCEL; the input log is left untouched. + """ + if parameters is None: + parameters = {} + + et = exec_utils.get_param_value(Parameters.EVENT_TYPE, parameters, None) + ot = exec_utils.get_param_value(Parameters.OBJECT_TYPE, parameters, None) + qualifiers: Optional[Iterable[Any]] = exec_utils.get_param_value( + Parameters.QUALIFIERS, parameters, None + ) + fmt: Callable[[str, str], str] = exec_utils.get_param_value( + Parameters.TUPLE_FORMAT, parameters, _default_format + ) + + if et is None: + raise ValueError("unfold requires Parameters.EVENT_TYPE") + if ot is None: + raise ValueError("unfold requires Parameters.OBJECT_TYPE") + + result = copy_mod.deepcopy(ocel) + eid_col = result.event_id_column + act_col = result.event_activity + type_col = result.object_type_column + qual_col = result.qualifier + + rel = result.relations + mask = (rel[act_col] == et) & (rel[type_col] == ot) + if qualifiers is not None: + mask = mask & rel[qual_col].isin(list(qualifiers)) + + matched_eids = set(rel.loc[mask, eid_col].unique()) + + if matched_eids: + new_act = fmt(et, ot) + ev_mask = result.events[eid_col].isin(matched_eids) + result.events.loc[ev_mask, act_col] = new_act + rel_mask = result.relations[eid_col].isin(matched_eids) + result.relations.loc[rel_mask, act_col] = new_act + + return result diff --git a/pm4py/ocel.py b/pm4py/ocel.py index 4304d8edd..dfb796e73 100644 --- a/pm4py/ocel.py +++ b/pm4py/ocel.py @@ -841,3 +841,187 @@ def cluster_equivalent_ocel( ret[descr] = [] ret[descr].append(oc) return ret + + +def ocel_drill_down( + ocel: OCEL, object_type: str, object_attribute: str +) -> OCEL: + """ + Drill-down OLAP operation on an object-centric event log: splits a given + object type into finer-grained tuple-style sub-types based on the values + of a given object attribute. + + For every object whose ``ocel:type`` equals ``object_type`` and whose + ``object_attribute`` value is defined, the type is rewritten to + ``"(object_type, value)"``. Objects with an undefined attribute value + keep their original type. The operation is information-preserving and + reversible via :func:`ocel_roll_up`. + + Reference: Khayatbashi, Miri, Jalali, "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations", CAiSE Forum 2025 + (arXiv:2412.00393). + + :param ocel: Object-centric event log. + :type ocel: OCEL + :param object_type: Object type to split. + :type object_type: str + :param object_attribute: Object attribute column whose values drive + the split. + :type object_attribute: str + :return: A new OCEL with the drilled-down object type. + :rtype: OCEL + + .. code-block:: python3 + + import pm4py + + drilled = pm4py.ocel_drill_down(ocel, "Test", "test_type") + """ + from pm4py.algo.transformation.ocel.olap.drill_down import ( + algorithm as drill_down_algorithm, + ) + from pm4py.algo.transformation.ocel.olap.drill_down.variants import ( + classic as drill_down_classic, + ) + + parameters = { + drill_down_classic.Parameters.OBJECT_TYPE: object_type, + drill_down_classic.Parameters.OBJECT_ATTRIBUTE: object_attribute, + } + return drill_down_algorithm.apply(ocel, parameters=parameters) + + +def ocel_roll_up( + ocel: OCEL, object_type: str, object_attribute: Optional[str] = None +) -> OCEL: + """ + Roll-up OLAP operation on an object-centric event log: collapses + tuple-style sub-types of the given parent ``object_type`` back to the + parent type. Inverse of :func:`ocel_drill_down`. + + Reference: Khayatbashi, Miri, Jalali, "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations", CAiSE Forum 2025 + (arXiv:2412.00393). + + :param ocel: Object-centric event log. + :type ocel: OCEL + :param object_type: Parent object type to merge sub-types back to. + :type object_type: str + :param object_attribute: Optional, accepted for API symmetry with + :func:`ocel_drill_down`. When provided it must be a column of + ``ocel.objects``, but it is not semantically required because the + tuple type value already encodes the split. + :type object_attribute: Optional[str] + :return: A new OCEL with the rolled-up object type. + :rtype: OCEL + + .. code-block:: python3 + + import pm4py + + rolled = pm4py.ocel_roll_up(drilled, "Test") + """ + from pm4py.algo.transformation.ocel.olap.roll_up import ( + algorithm as roll_up_algorithm, + ) + from pm4py.algo.transformation.ocel.olap.roll_up.variants import ( + classic as roll_up_classic, + ) + + parameters = { + roll_up_classic.Parameters.OBJECT_TYPE: object_type, + roll_up_classic.Parameters.OBJECT_ATTRIBUTE: object_attribute, + } + return roll_up_algorithm.apply(ocel, parameters=parameters) + + +def ocel_unfold( + ocel: OCEL, + event_type: str, + object_type: str, + qualifiers: Optional[Collection[Any]] = None, +) -> OCEL: + """ + Unfold OLAP operation on an object-centric event log: splits an event + type into a tuple-style sub-type keyed by a related object type, + optionally restricted to a set of E2O qualifiers. + + Every event of type ``event_type`` that has at least one matching + E2O relation to an object of type ``object_type`` has its activity + rewritten to ``"(event_type, object_type)"`` in both ``ocel.events`` + and ``ocel.relations``. Reversible via :func:`ocel_fold`. + + Reference: Khayatbashi, Miri, Jalali, "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations", CAiSE Forum 2025 + (arXiv:2412.00393). + + :param ocel: Object-centric event log. + :type ocel: OCEL + :param event_type: Event type/activity to unfold. + :type event_type: str + :param object_type: Related object type to unfold over. + :type object_type: str + :param qualifiers: Optional collection of qualifier values to filter + the driving E2O relations. ``None`` means all qualifiers. + :type qualifiers: Optional[Collection[Any]] + :return: A new OCEL with the unfolded event type. + :rtype: OCEL + + .. code-block:: python3 + + import pm4py + + unfolded = pm4py.ocel_unfold(ocel, "order test", "(Test, ECG)") + """ + from pm4py.algo.transformation.ocel.olap.unfold import ( + algorithm as unfold_algorithm, + ) + from pm4py.algo.transformation.ocel.olap.unfold.variants import ( + classic as unfold_classic, + ) + + parameters = { + unfold_classic.Parameters.EVENT_TYPE: event_type, + unfold_classic.Parameters.OBJECT_TYPE: object_type, + unfold_classic.Parameters.QUALIFIERS: qualifiers, + } + return unfold_algorithm.apply(ocel, parameters=parameters) + + +def ocel_fold(ocel: OCEL, event_type: str, object_type: str) -> OCEL: + """ + Fold OLAP operation on an object-centric event log: collapses the + unfolded activity ``"(event_type, object_type)"`` back to + ``event_type``. Inverse of :func:`ocel_unfold`. + + Reference: Khayatbashi, Miri, Jalali, "Advancing Object-Centric Process + Mining with Multi-Dimensional Data Operations", CAiSE Forum 2025 + (arXiv:2412.00393). + + :param ocel: Object-centric event log. + :type ocel: OCEL + :param event_type: Parent event type to merge back to. + :type event_type: str + :param object_type: Object type used during the matching unfold. + :type object_type: str + :return: A new OCEL with the folded event type. + :rtype: OCEL + + .. code-block:: python3 + + import pm4py + + folded = pm4py.ocel_fold(unfolded, "order test", "(Test, ECG)") + """ + from pm4py.algo.transformation.ocel.olap.fold import ( + algorithm as fold_algorithm, + ) + from pm4py.algo.transformation.ocel.olap.fold.variants import ( + classic as fold_classic, + ) + + parameters = { + fold_classic.Parameters.EVENT_TYPE: event_type, + fold_classic.Parameters.OBJECT_TYPE: object_type, + } + return fold_algorithm.apply(ocel, parameters=parameters) diff --git a/tests/ocel_olap_test.py b/tests/ocel_olap_test.py new file mode 100644 index 000000000..0baa9bd27 --- /dev/null +++ b/tests/ocel_olap_test.py @@ -0,0 +1,242 @@ +import os +import unittest + +import pm4py +from pm4py.objects.ocel.obj import OCEL + + +LOG_PATH = os.path.join("input_data", "ocel", "example_log.jsonocel") + + +class OcelOlapTest(unittest.TestCase): + def _load(self): + return pm4py.read_ocel(LOG_PATH) + + def test_drill_down_splits_types(self): + ocel = self._load() + type_col = ocel.object_type_column + original_types = set(ocel.objects[type_col].unique()) + + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + + self.assertIsInstance(drilled, OCEL) + new_types = set(drilled.objects[type_col].unique()) + # The drilled log must contain at least one tuple-style sub-type. + self.assertTrue( + any(isinstance(t, str) and t.startswith("(element, ") for t in new_types) + ) + # More distinct types than before (element splits into several). + self.assertGreater(len(new_types), len(original_types)) + # Relations table is synced with the new object types. + rel_types = set(drilled.relations[type_col].unique()) + self.assertTrue( + any(isinstance(t, str) and t.startswith("(element, ") for t in rel_types) + ) + # Input log was not mutated. + self.assertEqual(set(ocel.objects[type_col].unique()), original_types) + + def test_drill_down_skips_undefined(self): + ocel = self._load() + type_col = ocel.object_type_column + oid_col = ocel.object_id_column + + # "i4" has NaN oattr1 in the example log; it must retain the + # original "element" type. + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + i4_rows = drilled.objects[drilled.objects[oid_col] == "i4"] + self.assertEqual(len(i4_rows), 1) + self.assertEqual(i4_rows.iloc[0][type_col], "element") + + def test_drill_down_roll_up_roundtrip(self): + ocel = self._load() + type_col = ocel.object_type_column + + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + rolled = pm4py.ocel_roll_up(drilled, object_type="element") + + self.assertTrue( + rolled.objects[type_col] + .reset_index(drop=True) + .equals(ocel.objects[type_col].reset_index(drop=True)) + ) + self.assertTrue( + rolled.relations[type_col] + .reset_index(drop=True) + .equals(ocel.relations[type_col].reset_index(drop=True)) + ) + + def test_unfold_splits_event_type(self): + ocel = self._load() + act_col = ocel.event_activity + + unfolded = pm4py.ocel_unfold( + ocel, event_type="Create Order", object_type="order" + ) + + self.assertIsInstance(unfolded, OCEL) + self.assertIn( + "(Create Order, order)", + set(unfolded.events[act_col].unique()), + ) + self.assertIn( + "(Create Order, order)", + set(unfolded.relations[act_col].unique()), + ) + # Input log was not mutated. + self.assertNotIn( + "(Create Order, order)", set(ocel.events[act_col].unique()) + ) + + def test_unfold_fold_roundtrip(self): + ocel = self._load() + act_col = ocel.event_activity + + unfolded = pm4py.ocel_unfold( + ocel, event_type="Create Order", object_type="order" + ) + folded = pm4py.ocel_fold( + unfolded, event_type="Create Order", object_type="order" + ) + + self.assertTrue( + folded.events[act_col] + .reset_index(drop=True) + .equals(ocel.events[act_col].reset_index(drop=True)) + ) + self.assertTrue( + folded.relations[act_col] + .reset_index(drop=True) + .equals(ocel.relations[act_col].reset_index(drop=True)) + ) + + def test_unfold_with_empty_qualifier_set_is_noop(self): + ocel = self._load() + act_col = ocel.event_activity + + unfolded = pm4py.ocel_unfold( + ocel, + event_type="Create Order", + object_type="order", + qualifiers=[], + ) + self.assertNotIn( + "(Create Order, order)", + set(unfolded.events[act_col].unique()), + ) + + def test_drill_down_invalid_object_type_raises(self): + ocel = self._load() + with self.assertRaises(ValueError): + pm4py.ocel_drill_down( + ocel, + object_type="__no_such_type__", + object_attribute="oattr1", + ) + + def test_drill_down_invalid_attribute_raises(self): + ocel = self._load() + with self.assertRaises(ValueError): + pm4py.ocel_drill_down( + ocel, + object_type="element", + object_attribute="__no_such_attr__", + ) + + def test_drilled_log_still_discovers_ocdfg(self): + ocel = self._load() + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + # Smoke test: downstream OC-DFG discovery must still work. + ocdfg = pm4py.discover_ocdfg(drilled) + self.assertIsNotNone(ocdfg) + + def test_drill_down_roll_up_full_equality(self): + # Stronger than the column-level round-trip above: the rolled-up + # OCEL must be fully equal to the original via OCEL.__eq__. + ocel = self._load() + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + rolled = pm4py.ocel_roll_up(drilled, object_type="element") + self.assertEqual(rolled, ocel) + + def test_unfold_fold_full_equality(self): + ocel = self._load() + unfolded = pm4py.ocel_unfold( + ocel, event_type="Create Order", object_type="order" + ) + folded = pm4py.ocel_fold( + unfolded, event_type="Create Order", object_type="order" + ) + self.assertEqual(folded, ocel) + + def test_drill_down_is_idempotent(self): + ocel = self._load() + once = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + twice = pm4py.ocel_drill_down( + once, object_type="element", object_attribute="oattr1" + ) + # Applying drill-down a second time must be a no-op: the "element" + # rows left after the first pass have undefined oattr1 and are + # skipped, and the already-drilled tuple-typed rows no longer match + # the target type. + self.assertEqual(twice, once) + + def test_drill_down_preserves_auxiliary_tables(self): + ocel = self._load() + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + self.assertTrue(drilled.o2o.equals(ocel.o2o)) + self.assertTrue(drilled.e2e.equals(ocel.e2e)) + self.assertTrue( + drilled.object_changes.equals(ocel.object_changes) + ) + + def test_drill_down_assigns_expected_tuple_values(self): + # Ground-truth check: verify specific objects receive the exact + # tuple-typed name derived from their oattr1 value. Without this + # it would be possible for drill-down to mis-align values with + # objects (e.g. due to a faulty mask/list assignment). + ocel = self._load() + drilled = pm4py.ocel_drill_down( + ocel, object_type="element", object_attribute="oattr1" + ) + type_col = drilled.object_type_column + oid_col = drilled.object_id_column + indexed = drilled.objects.set_index(oid_col)[type_col].to_dict() + self.assertEqual(indexed["i1"], "(element, due)") + self.assertEqual(indexed["i2"], "(element, tre)") + self.assertEqual(indexed["i3"], "(element, quattro)") + # i4 has NaN oattr1 and must keep the original type. + self.assertEqual(indexed["i4"], "element") + + def test_unfold_with_matching_qualifier(self): + # Positive case for the qualifier filter: the example log uses the + # empty-string qualifier for every E2O relation, so passing it + # explicitly must match and rename just like the default None. + ocel = self._load() + act_col = ocel.event_activity + unfolded = pm4py.ocel_unfold( + ocel, + event_type="Create Order", + object_type="order", + qualifiers=[""], + ) + self.assertIn( + "(Create Order, order)", + set(unfolded.events[act_col].unique()), + ) + + +if __name__ == "__main__": + unittest.main()