DuckDB-backed OCEL 2.0 analysis with typed lazy queries
Project description
oceldb
DuckDB-backed dataframe layer for OCEL 2.0.
oceldb exposes object-centric event logs through a typed, lazy dataframe API
backed by DuckDB. Ibis is used internally, but queries are built through
oceldb's typed expression wrappers. OCEL 2.0 SQLite exports are converted to
a typed Parquet layout before analysis. Nothing is materialised into Python
objects until you explicitly call .execute().
Installation
pip install oceldb
# or
uv add oceldb
Requires Python 3.11+.
Quick start
from oceldb import OCEL, col, desc
from oceldb.io import convert_ocel
# Convert an OCEL 2.0 SQLite file to the oceldb Parquet layout
convert_ocel("running-example.sqlite", "running-example", overwrite=True)
ocel = OCEL.read("running-example")
# Event counts by type
event_counts = (
ocel.events()
.group_by("ocel_type")
.aggregate(n=col("ocel_id").count())
.order_by(desc("n"))
.execute()
)
# Latest state of each order
latest_states = ocel.object_states("order").latest().execute()
# Filter objects using a predicate
from oceldb.predicates import participated_in
paid_orders = ocel.objects("order").filter(participated_in(ocel, "Pay Order"))
print(paid_orders.count().execute())
Core concepts
OCEL 2.0 extends flat event logs with multiple interrelated object types. An order-fulfilment process, for example, involves orders, items, packages, and payments — each with their own lifecycle. oceldb stores this structure natively.
The OCEL class
OCEL.read(path) opens a persisted Parquet log directory and returns an
OCEL instance backed by lazy oceldb.Table expressions.
ocel = OCEL.read("my-log")
ocel.events() # all events: ocel_id, ocel_type, ocel_time, …attrs
ocel.events("Place Order") # filtered to one type
ocel.events("A", "B") # filtered to multiple types
ocel.objects() # all objects: ocel_id, ocel_type
ocel.objects("order") # filtered to one type
ocel.object_changes("order") # sparse history rows for object type
ocel.object_states("order") # fill-forward state view (see below)
ocel.flatten("order") # case-centric event log with orders as cases
ocel.event_object # E2O bridge: event_id, event_type, object_id, object_type, qualifier
ocel.object_object # O2O bridge: source_id, source_type, target_id, target_type, qualifier
ocel.manifest # parsed manifest.json (counts, types, time range)
Object states
Attributes in OCEL 2.0 are stored as change rows. object_states reconstructs
point-in-time snapshots via fill-forward:
from datetime import datetime
states = ocel.object_states("order")
states.history() # all change rows in chronological order
states.latest() # most recent state per object
states.as_of(datetime(2024, 6, 1)) # state at a specific timestamp
Flattening
Flatten an OCEL to a traditional case-centric event log by choosing one object type as the case notion:
flat_orders = ocel.flatten("order").execute()
The flattened table follows the XES naming convention:
case:concept:name: object id of the selected object typecase:<attribute>: selected object's state at the event timestampconcept:name: event activity, fromocel_typetime:timestamp: event timestamp, fromocel_timeocel_event_id: original OCEL event id- event attributes are preserved as additional columns
Typed expressions
All accessors return oceldb.Table expressions. Use the wrappers exported by
oceldb for expression construction; do not compose queries with Ibis
objects directly. The wrappers keep the supported expression surface typed
despite Ibis' dynamic typing internals.
from oceldb import col, desc, row_number
# Latest object states with a specific attribute value
heavy = ocel.object_states("Container").latest().filter(
col("Weight") > 500
)
# Event timeline per flattened case with lag/lead
flat = ocel.flatten("order")
timeline = flat.mutate(
seq=row_number().over(
group_by="case:concept:name", order_by=["time:timestamp", "ocel_event_id"]
),
previous=col("concept:name").lag().over(
group_by="case:concept:name", order_by=["time:timestamp", "ocel_event_id"]
),
next=col("concept:name").lead().over(
group_by="case:concept:name", order_by=["time:timestamp", "ocel_event_id"]
),
).order_by("case:concept:name", "time:timestamp").execute()
# Aggregation and ordering
counts = (
ocel.events()
.group_by("ocel_type")
.aggregate(n=col("ocel_id").count())
.order_by(desc("n"))
.execute()
)
The typed surface includes Table.filter, select, mutate, drop,
rename, distinct, limit, order_by, group_by, join, execute,
and to_pyarrow; column comparisons and aggregations; and the col, asc,
desc, row_number, and union helpers.
Predicates
oceldb.predicates provides free-function predicates for OCEL-specific filter
expressions. All return deferred boolean expressions for use with
oceldb.Table.filter().
Object predicates
from oceldb.predicates import (
participated_in, # object participated in ≥1 event of a given type
cooccurrence_count, # count of co-occurring objects of a given type
e2o_count, # count of E2O-linked events of a given type
o2o_count, # count of O2O-linked objects of a given type
o2o_reachable, # reachability via O2O relations
)
# Orders that were paid
paid = ocel.objects("order").filter(participated_in(ocel, "Pay Order"))
# Orders with exactly one Pay Order event
ocel.objects("order").filter(e2o_count(ocel, "Pay Order", target="event") == 1)
# Orders with more than 3 items co-occurring
busy = ocel.objects("order").filter(cooccurrence_count(ocel, "item") >= 3)
# Objects with exactly two linked children
ocel.objects("order").filter(o2o_count(ocel, "item") == 2)
# Objects linked (directly or transitively) to a Transport Document
ocel.objects("Container").filter(o2o_reachable(ocel, "Transport Document"))
Event predicates
from oceldb.predicates import (
e2o_count, # count of E2O-linked objects of a given type
involves, # event involves ≥1 object of a given type
has_matching_predecessor, # batch-synchronisation check
)
# Events that involve at least one item
ocel.events().filter(involves(ocel, "item"))
# Events with exactly two linked items
ocel.events().filter(e2o_count(ocel, "item") == 2)
# Sync events that have a matching preceding Group event
matched = ocel.events("Sync").filter(
has_matching_predecessor(ocel, "Group", "member")
)
Count predicates
cooccurrence_count, e2o_count, o2o_count return a CountPredicate
object that supports threshold comparisons:
For e2o_count, target="object" counts objects per event, while
target="event" counts events per object.
e2o_count(ocel, "Pay Order", target="event") >= 1 # participated at least once
e2o_count(ocel, "Pay Order", target="event") == 1 # participated exactly once
e2o_count(ocel, "Pay Order", target="event") == 0 # never participated
Inspection
oceldb.inspect provides structural summaries read directly from the manifest
(no SQL):
from oceldb.inspect import overview, event_types, object_types
print(overview(ocel))
# Events: 35,413 (14 types)
# Objects: 13,910 (7 types)
# E2O relations: 74,334
# O2O relations: 15,953
# Time range: 2023-05-22 11:54:42 → 2024-08-22 12:18:41
for et in event_types(ocel): # sorted by count descending
print(et.name, et.count, et.attributes)
for ot in object_types(ocel): # sorted by count descending
print(ot.name, ot.object_count, ot.attributes)
Import
Convert an OCEL 2.0 SQLite export to the oceldb Parquet layout:
from oceldb.io import convert_ocel
convert_ocel("source.sqlite", "target/", overwrite=True)
Supported source extensions are .db, .sqlite, and .sqlite3.
Storage layout
my-log/
manifest.json # schema, provenance, totals
events/
ocel_type=<url-encoded-type>/
data.parquet # ocel_id, ocel_time, …attrs; sorted by ocel_time
objects/
ocel_type=<url-encoded-type>/
data.parquet # ocel_id
object_changes/
ocel_type=<url-encoded-type>/
data.parquet # ocel_id, ocel_time, ocel_changed_field, …attrs
event_object.parquet # E2O bridge with denormalised type columns
object_object.parquet # O2O bridge with denormalised type columns (if present)
Type names with spaces or special characters are URL-encoded in directory names
(e.g. ocel_type=Place%20Order). DuckDB uses Hive partition pruning to skip
files when filtering by type.
Type checking
Ibis' expression types are dynamic and produce pervasive errors in strict
pyright/basedpyright projects. For that reason, Ibis is an implementation
detail: public query methods return oceldb.Table, and supported query
operations are exposed through the wrappers in oceldb.expr.
from oceldb import OCEL, col, desc
with OCEL.read("my-log") as ocel:
result = (
ocel.events("Place Order")
.filter(col("amount") > 100)
.order_by(desc("ocel_time"))
.execute()
)
Keep expressions within this typed API. Importing Ibis expressions directly or operating on the underlying raw backend bypasses the wrapper layer and brings back the typing problems it is intended to isolate.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file oceldb-0.8.0.tar.gz.
File metadata
- Download URL: oceldb-0.8.0.tar.gz
- Upload date:
- Size: 24.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21cdb17635e2efa036e87b92ee0e46c4f898134ee2218066831f273d94a896e3
|
|
| MD5 |
98116e6d06d647e1c2eeff82391e961e
|
|
| BLAKE2b-256 |
32d1fb764713e81c772192df2b8870b6340e626ca6ab0d1cc7627201cceb235f
|
Provenance
The following attestation bundles were made for oceldb-0.8.0.tar.gz:
Publisher:
publish.yml on sebmne/oceldb
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
oceldb-0.8.0.tar.gz -
Subject digest:
21cdb17635e2efa036e87b92ee0e46c4f898134ee2218066831f273d94a896e3 - Sigstore transparency entry: 1732104077
- Sigstore integration time:
-
Permalink:
sebmne/oceldb@eb531b2fec5d812cda52bf5bcf44e79b1d1deec8 -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/sebmne
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@eb531b2fec5d812cda52bf5bcf44e79b1d1deec8 -
Trigger Event:
release
-
Statement type:
File details
Details for the file oceldb-0.8.0-py3-none-any.whl.
File metadata
- Download URL: oceldb-0.8.0-py3-none-any.whl
- Upload date:
- Size: 26.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ee2148c039183cd1451f5ad6e57c1ca7d3fb4f624741dd2f484687fefbffa255
|
|
| MD5 |
3e8fd90eec049e35c971d8f53fe0df87
|
|
| BLAKE2b-256 |
5033c26968a6d3e19fb44f13a7b7aecea49f315bf9f0d15a2ea7ac259c4dafa9
|
Provenance
The following attestation bundles were made for oceldb-0.8.0-py3-none-any.whl:
Publisher:
publish.yml on sebmne/oceldb
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
oceldb-0.8.0-py3-none-any.whl -
Subject digest:
ee2148c039183cd1451f5ad6e57c1ca7d3fb4f624741dd2f484687fefbffa255 - Sigstore transparency entry: 1732104098
- Sigstore integration time:
-
Permalink:
sebmne/oceldb@eb531b2fec5d812cda52bf5bcf44e79b1d1deec8 -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/sebmne
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@eb531b2fec5d812cda52bf5bcf44e79b1d1deec8 -
Trigger Event:
release
-
Statement type: