ocelescope-module-ocelot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.3
2
+ Name: ocelescope-module-ocelot
3
+ Version: 0.1.0
4
+ Summary: An ocelescope module for giving paginated exploration in ocelescope
5
+ Requires-Dist: ocelescope-backend>=0.1.1
6
+ Requires-Python: >=3.11
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "ocelescope-module-ocelot"
3
+ version = "0.1.0"
4
+ description = "An ocelescope module for giving paginated exploration in ocelescope"
5
+ requires-python = ">=3.11"
6
+ dependencies = ["ocelescope-backend>=0.1.1"]
7
+
8
+ [project.entry-points."ocelescope_backend.modules"]
9
+ ocelot = "ocelescope_module_ocelot.module:Ocelot"
10
+
11
+ [build-system]
12
+ requires = ["uv_build>=0.11.20,<0.12.0"]
13
+ build-backend = "uv_build"
14
+
15
+ [tool.uv]
16
+ package = true
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Any, Dict, List, Literal, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class OcelEntity(BaseModel):
9
+ id: str
10
+ timestamp: Optional[datetime] = None
11
+ attributes: Dict[str, Any]
12
+ relations: Dict[str, List[str]]
13
+
14
+
15
+ class PaginatedResponse(BaseModel):
16
+ page: int
17
+ page_size: int
18
+ total_pages: int
19
+ total_items: int
20
+ items: List[OcelEntity]
21
+
22
+
23
+ @dataclass
24
+ class EntityTableColumn:
25
+ accessor: str
26
+ type: Literal["attribute", "relation"]
27
+ title: str | None = None
@@ -0,0 +1,19 @@
1
+ from fastapi import FastAPI
2
+ from ocelescope_backend.app.modules import Module, ModuleMeta
3
+ from packaging.version import Version
4
+
5
+ from ocelescope_module_ocelot.routes import router
6
+
7
+
8
+ class Ocelot(Module):
9
+ meta = ModuleMeta(key="ocelot", version=Version("1.0"))
10
+
11
+ @classmethod
12
+ def create_app(cls) -> FastAPI:
13
+ app = FastAPI(
14
+ title="Ocelot", version=str(cls.meta.version), docs_url=None, redoc_url=None
15
+ )
16
+
17
+ app.include_router(router)
18
+
19
+ return app
@@ -0,0 +1,70 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Query
4
+ from ocelescope_backend.app.dependencies import ApiOcel
5
+
6
+ from ocelescope_module_ocelot.models import EntityTableColumn, PaginatedResponse
7
+ from ocelescope_module_ocelot.util import (
8
+ get_activity_columns_def,
9
+ get_object_columns_def,
10
+ get_paginated_event_table,
11
+ get_paginated_object_table,
12
+ )
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.get(
18
+ "/{ocel_id}/events",
19
+ response_model=PaginatedResponse,
20
+ operation_id="paginatedEvents",
21
+ )
22
+ def get_events(
23
+ ocel: ApiOcel,
24
+ type: Annotated[str, Query(description="Activity name")],
25
+ page: Annotated[int, Query(ge=1)] = 1,
26
+ page_size: Annotated[int, Query(ge=1, le=100)] = 10,
27
+ sort_by: Annotated[str | None, Query()] = None,
28
+ ascending: Annotated[bool, Query()] = True,
29
+ ):
30
+ return get_paginated_event_table(
31
+ ocel=ocel,
32
+ activity=type,
33
+ page_index=page,
34
+ page_size=page_size,
35
+ sort_by=sort_by,
36
+ ascending=ascending,
37
+ )
38
+
39
+
40
+ @router.get(
41
+ "/{ocel_id}/objects",
42
+ response_model=PaginatedResponse,
43
+ operation_id="paginatedObjects",
44
+ )
45
+ def get_objects(
46
+ ocel: ApiOcel,
47
+ type: Annotated[str, Query(description="Object type name")],
48
+ page: Annotated[int, Query(ge=1)] = 1,
49
+ page_size: Annotated[int, Query(ge=1, le=100)] = 10,
50
+ sort_by: Annotated[str | None, Query()] = None,
51
+ ascending: Annotated[bool, Query()] = True,
52
+ ):
53
+ return get_paginated_object_table(
54
+ ocel=ocel,
55
+ object_type=type,
56
+ page_index=page,
57
+ page_size=page_size,
58
+ sort_by=sort_by,
59
+ ascending=ascending,
60
+ )
61
+
62
+
63
+ @router.get("/{ocel_id}/objects/columns", operation_id="objectColumns")
64
+ def get_object_columns(ocel: ApiOcel, type_name: str) -> list[EntityTableColumn]:
65
+ return get_object_columns_def(ocel, type_name)
66
+
67
+
68
+ @router.get("/{ocel_id}/events/columns", operation_id="activityColumns")
69
+ def get_activity_columns(ocel: ApiOcel, type_name: str) -> list[EntityTableColumn]:
70
+ return get_activity_columns_def(ocel, type_name)
@@ -0,0 +1,336 @@
1
+ import math
2
+ from typing import Any, Iterable, TypeVar
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from ocelescope import OCEL
7
+ from ocelescope.ocel.constants import (
8
+ ACTIVITY_COL,
9
+ E2O_ACTIVITY,
10
+ E2O_OBJECT_TYPE,
11
+ O2O_SOURCE_TYPE,
12
+ )
13
+ from ocelescope.ocel.constants.pm4py import (
14
+ E2O_QUALIFIER,
15
+ EID_COL,
16
+ O2O_QUALIFIER,
17
+ O2O_SOURCE_ID,
18
+ O2O_TARGET_ID,
19
+ O2O_TARGET_TYPE,
20
+ OID_COL,
21
+ OTYPE_COL,
22
+ TIMESTAMP_COL,
23
+ )
24
+ from ocelescope.util.pandas import coerce_series
25
+
26
+ from ocelescope_module_ocelot.models import (
27
+ EntityTableColumn,
28
+ OcelEntity,
29
+ PaginatedResponse,
30
+ )
31
+
32
+ T = TypeVar("T")
33
+
34
+
35
+ def to_python_scalar(value: Any) -> Any:
36
+ if isinstance(value, np.generic):
37
+ return value.item()
38
+ return value
39
+
40
+
41
+ def none_or_python_value(value: Any) -> Any | None:
42
+ if value is None:
43
+ return None
44
+ if isinstance(value, str) and not value.strip():
45
+ return None
46
+ if pd.api.types.is_scalar(value) and pd.isna(value):
47
+ return None
48
+ return to_python_scalar(value)
49
+
50
+
51
+ def first_present(values: Iterable[Any]) -> Any | None:
52
+ for value in values:
53
+ cleaned_value = none_or_python_value(value)
54
+ if cleaned_value is not None:
55
+ return cleaned_value
56
+ return None
57
+
58
+
59
+ def relation_accessor(parts: Iterable[Any]) -> str:
60
+ return str(first_present(parts))
61
+
62
+
63
+ def paginate_df(
64
+ df: pd.DataFrame,
65
+ page_size: int,
66
+ page_index: int,
67
+ ) -> tuple[pd.DataFrame, int, int]:
68
+ if page_size <= 0:
69
+ raise ValueError("page_size must be greater than 0")
70
+ if page_index <= 0:
71
+ raise ValueError("page_index must be greater than 0")
72
+
73
+ total_items = len(df)
74
+ total_pages = math.ceil(total_items / page_size) if total_items else 0
75
+
76
+ if total_pages > 0 and page_index > total_pages:
77
+ raise ValueError(f"page_index {page_index} exceeds total_pages {total_pages}")
78
+
79
+ start = (page_index - 1) * page_size
80
+ end = page_index * page_size
81
+ return df.iloc[start:end], total_items, total_pages
82
+
83
+
84
+ def serialize_object_attribute(value: Any) -> Any:
85
+ if isinstance(value, (str, bytes, dict)) or not isinstance(value, Iterable):
86
+ return to_python_scalar(value)
87
+
88
+ return first_present(reversed(value))
89
+
90
+
91
+ def get_object_columns_def(
92
+ ocel: OCEL,
93
+ object_type: str,
94
+ ) -> list[EntityTableColumn]:
95
+ typed_o2o = ocel.o2o.typed_df
96
+
97
+ relation_rows = typed_o2o.loc[
98
+ typed_o2o[O2O_SOURCE_TYPE].eq(object_type)
99
+ ].drop_duplicates([O2O_TARGET_TYPE, O2O_QUALIFIER])[
100
+ [O2O_TARGET_TYPE, O2O_QUALIFIER]
101
+ ]
102
+
103
+ relation_columns = [
104
+ EntityTableColumn(
105
+ accessor=relation_accessor([qualifier, target_object_type]),
106
+ type="relation",
107
+ title=f"{qualifier} ({target_object_type})",
108
+ )
109
+ for _, target_object_type, qualifier in relation_rows.itertuples()
110
+ ]
111
+
112
+ attribute_columns = [
113
+ EntityTableColumn(accessor=attribute, type="attribute")
114
+ for _, attribute in ocel.attributes.get_object_summary(
115
+ object_types=[object_type]
116
+ ).index
117
+ ]
118
+
119
+ return [
120
+ EntityTableColumn(accessor="id", type="relation", title="#"),
121
+ *attribute_columns,
122
+ *relation_columns,
123
+ ]
124
+
125
+
126
+ def get_activity_columns_def(
127
+ ocel: OCEL,
128
+ activity_name: str,
129
+ ) -> list[EntityTableColumn]:
130
+ typed_e2o = ocel.e2o.df
131
+
132
+ relation_rows = typed_e2o.loc[
133
+ typed_e2o[E2O_ACTIVITY].eq(activity_name)
134
+ ].drop_duplicates([E2O_OBJECT_TYPE, E2O_QUALIFIER])[
135
+ [E2O_OBJECT_TYPE, E2O_QUALIFIER]
136
+ ]
137
+
138
+ relation_columns = [
139
+ EntityTableColumn(
140
+ accessor=relation_accessor([qualifier, object_type]),
141
+ type="relation",
142
+ title=f"{qualifier} ({object_type})",
143
+ )
144
+ for _, object_type, qualifier in relation_rows.itertuples()
145
+ ]
146
+
147
+ attribute_columns = [
148
+ EntityTableColumn(accessor=attribute, type="attribute")
149
+ for _, attribute in ocel.attributes.get_activity_summary(
150
+ activities=[activity_name]
151
+ ).index
152
+ ]
153
+
154
+ return [
155
+ EntityTableColumn(accessor="id", title="#", type="attribute"),
156
+ EntityTableColumn(
157
+ accessor="timestamp",
158
+ title="Timestamp",
159
+ type="attribute",
160
+ ),
161
+ *attribute_columns,
162
+ *relation_columns,
163
+ ]
164
+
165
+
166
+ def get_sort_by_key(sort_by, id_string):
167
+ match sort_by:
168
+ case "id":
169
+ return id_string
170
+ case "timestamp":
171
+ return TIMESTAMP_COL
172
+ case _:
173
+ return sort_by
174
+
175
+
176
+ def get_paginated_event_table(
177
+ ocel: OCEL,
178
+ page_size: int,
179
+ page_index: int,
180
+ activity: str,
181
+ sort_by: str | None = None,
182
+ ascending: bool = True,
183
+ ) -> PaginatedResponse:
184
+ events_table = ocel.events.df.loc[ocel.events.df[ACTIVITY_COL].eq(activity)]
185
+
186
+ if sort_by is not None:
187
+ events_table = events_table.sort_values(
188
+ by=get_sort_by_key(sort_by, EID_COL), ascending=ascending
189
+ )
190
+
191
+ events_table, total_entities, total_pages = paginate_df(
192
+ events_table,
193
+ page_size=page_size,
194
+ page_index=page_index,
195
+ )
196
+
197
+ events_table = events_table.dropna(axis=1, how="all")
198
+
199
+ e2o_table = (
200
+ ocel.e2o.df.loc[ocel.e2o.df[EID_COL].isin(events_table[EID_COL])]
201
+ .groupby([EID_COL, E2O_QUALIFIER, OTYPE_COL])[OID_COL]
202
+ .agg(list)
203
+ )
204
+
205
+ e2o_entity_ids = set(e2o_table.index.get_level_values(0))
206
+
207
+ return PaginatedResponse(
208
+ page=page_index,
209
+ page_size=page_size,
210
+ total_pages=total_pages,
211
+ total_items=total_entities,
212
+ items=[
213
+ OcelEntity(
214
+ id=str(entity_id),
215
+ timestamp=row[TIMESTAMP_COL],
216
+ attributes={
217
+ str(column_name): to_python_scalar(value)
218
+ for column_name, value in row.items()
219
+ if column_name not in [TIMESTAMP_COL, ACTIVITY_COL]
220
+ },
221
+ relations={
222
+ relation_accessor(index[1:]): objects
223
+ for index, objects in e2o_table.loc[[str(entity_id)]].items()
224
+ }
225
+ if entity_id in e2o_entity_ids
226
+ else {},
227
+ )
228
+ for entity_id, row in events_table.set_index(EID_COL).iterrows()
229
+ ],
230
+ )
231
+
232
+
233
+ def get_paginated_object_table(
234
+ ocel: OCEL,
235
+ page_size: int,
236
+ page_index: int,
237
+ object_type: str,
238
+ sort_by: str | None = None,
239
+ ascending: bool = True,
240
+ ) -> PaginatedResponse:
241
+ object_table = ocel.objects.df.loc[
242
+ ocel.objects.df[OTYPE_COL].eq(object_type)
243
+ ].set_index(OID_COL)
244
+
245
+ if sort_by:
246
+ if sort_by in ocel.objects.dynamic_attribute_names:
247
+ changes = (
248
+ ocel.objects.changes.loc[
249
+ ocel.objects.changes[OTYPE_COL].eq(object_type)
250
+ & ocel.objects.changes["ocel:field"].eq(sort_by),
251
+ [OID_COL, sort_by],
252
+ ]
253
+ .drop_duplicates(OID_COL, keep="last")
254
+ .set_index(OID_COL)
255
+ )
256
+
257
+ if sort_by not in object_table:
258
+ object_table[sort_by] = None
259
+
260
+ object_table.update(changes)
261
+
262
+ object_table[sort_by] = coerce_series(object_table[sort_by])
263
+ object_table = object_table.sort_values(
264
+ by=get_sort_by_key(sort_by, OID_COL), ascending=ascending
265
+ )
266
+
267
+ object_table, total_objects, total_pages = paginate_df(
268
+ object_table,
269
+ page_size=page_size,
270
+ page_index=page_index,
271
+ )
272
+
273
+ changes_table = ocel.objects.changes.loc[
274
+ ocel.objects.changes[OID_COL].isin(object_table.index)
275
+ ].dropna(axis=1, how="all")
276
+
277
+ if not changes_table.empty:
278
+ dynamic_attributes = changes_table["ocel:field"].drop_duplicates()
279
+ dynamic_attributes_aggr = {field: (field, list) for field in dynamic_attributes}
280
+
281
+ changes_table = changes_table.groupby(OID_COL).agg(
282
+ **dynamic_attributes_aggr,
283
+ **{TIMESTAMP_COL: (TIMESTAMP_COL, list)},
284
+ )
285
+
286
+ object_table = object_table.merge(
287
+ changes_table,
288
+ how="left",
289
+ left_index=True,
290
+ right_index=True,
291
+ suffixes=("", "_new"),
292
+ )
293
+
294
+ for column_name in dynamic_attributes:
295
+ object_table[column_name] = object_table[
296
+ f"{column_name}_new"
297
+ ].combine_first(object_table[column_name].astype(object))
298
+
299
+ object_table = object_table.drop(
300
+ columns=[f"{column_name}_new" for column_name in dynamic_attributes]
301
+ )
302
+
303
+ typed_o2o = ocel.o2o.typed_df
304
+ o2o_table = (
305
+ typed_o2o.loc[typed_o2o[O2O_SOURCE_ID].isin(object_table.index)]
306
+ .groupby([O2O_SOURCE_ID, O2O_QUALIFIER, O2O_TARGET_TYPE])[O2O_TARGET_ID]
307
+ .agg(list)
308
+ )
309
+
310
+ object_table = object_table.dropna(axis=1, how="all").drop(columns=[OTYPE_COL])
311
+
312
+ o2o_entity_ids = set(o2o_table.index.get_level_values(0))
313
+
314
+ return PaginatedResponse(
315
+ page=page_index,
316
+ page_size=page_size,
317
+ total_pages=total_pages,
318
+ total_items=total_objects,
319
+ items=[
320
+ OcelEntity(
321
+ id=str(entity_id),
322
+ attributes={
323
+ str(column_name): serialize_object_attribute(value)
324
+ for column_name, value in row.items()
325
+ if column_name != TIMESTAMP_COL
326
+ },
327
+ relations={
328
+ relation_accessor(index[1:]): list(objects)
329
+ for index, objects in o2o_table.loc[[str(entity_id)]].items()
330
+ }
331
+ if entity_id in o2o_entity_ids
332
+ else {},
333
+ )
334
+ for entity_id, row in object_table.iterrows()
335
+ ],
336
+ )