Skip to main content

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:

from oceldb import CaseCentricEventLog

flat_orders: CaseCentricEventLog = ocel.flatten("order")
orders = flat_orders.execute()

The flattened table follows the XES naming convention:

  • case:concept:name: object id of the selected object type
  • case:<attribute>: selected object's state at the event timestamp
  • concept:name: event activity, from ocel_type
  • time:timestamp: event timestamp, from ocel_time
  • ocel_event_id: original OCEL event id
  • event attributes are preserved as additional columns

Case-centric discovery functions accept CaseCentricEventLog values.

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
    time_between,        # object has two linked events within time bounds
)

# 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"))

# Visitors where check_ticket happens at least 10 minutes after check_visitor
from datetime import timedelta

delta = time_between(ocel, "check_visitor", "check_ticket")
ocel.objects("visitor").filter(
    timedelta(minutes=10) <= delta,
)

# Visitors where the two events are between 10 and 30 minutes apart
ocel.objects("visitor").filter(
    timedelta(minutes=10) <= delta,
    delta <= timedelta(minutes=30),
)

# Equivalent single-predicate form
ocel.objects("visitor").filter(delta.between(timedelta(minutes=10), timedelta(minutes=30)))

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)

Analysis helpers

oceldb.analysis provides standalone convenience functions. They take an OCEL instance, compose the regular lazy table API internally, and return materialized Polars dataframes.

from oceldb import OCEL
from oceldb.analysis import (
    activity_counts,
    case_time_bounds,
    object_timeline,
    start_activity_counts,
)

with OCEL.read("my-log") as ocel:
    print(activity_counts(ocel))
    print(start_activity_counts(ocel, "order"))
    print(case_time_bounds(ocel, "order"))

    timeline = object_timeline(ocel, "order").head(20)
    print(timeline)

Discovery

oceldb.case_centric provides discovery over flattened case-centric event logs: first discover a directly-follows graph, then a process tree, then synthesize a Petri net. oceldb.discovery provides object-centric discovery directly over persisted OCEL tables by applying that case-centric pipeline per selected object type, merging transitions with equal activity labels, and marking variable arcs where one event can involve multiple objects of the same type.

from oceldb import OCEL, visualize
from oceldb.case_centric import (
    discover_dfg,
    discover_process_tree,
    synthesize_petri_net,
)
from oceldb.discovery import discover_ocpn

with OCEL.read("my-log") as ocel:
    dfg = discover_dfg(ocel.flatten("order"))
    tree = discover_process_tree(dfg)
    order_net = synthesize_petri_net(tree, object_type="order")

    ocpn = discover_ocpn(ocel, "order", "item")
    dot = visualize(ocpn)
    dot.display()  # Inline in Jupyter notebooks
    dot.render("ocpn", format="svg", cleanup=True)

    print(len(order_net.places), len(ocpn.transitions), len(ocpn.arcs))

In notebooks, visualize(ocpn) also displays inline when it is the last expression in a cell. The rendered SVG is wrapped in a scrollable, responsive HTML container with a download link so larger nets fit notebook output cells and can be saved as SVG.

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

oceldb-0.9.0.tar.gz (43.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

oceldb-0.9.0-py3-none-any.whl (52.3 kB view details)

Uploaded Python 3

File details

Details for the file oceldb-0.9.0.tar.gz.

File metadata

  • Download URL: oceldb-0.9.0.tar.gz
  • Upload date:
  • Size: 43.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for oceldb-0.9.0.tar.gz
Algorithm Hash digest
SHA256 90bbb7606bae9d74309e013044ad1d080512232d924edbf5a90603b1493a8b11
MD5 2a125363d29d44dbd9d5a2fa5cea87ea
BLAKE2b-256 7b1bca3f6e9ba23c5a1f650a806cf36949565b75d9dee7154003d33a93dca978

See more details on using hashes here.

Provenance

The following attestation bundles were made for oceldb-0.9.0.tar.gz:

Publisher: publish.yml on sebmne/oceldb

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file oceldb-0.9.0-py3-none-any.whl.

File metadata

  • Download URL: oceldb-0.9.0-py3-none-any.whl
  • Upload date:
  • Size: 52.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for oceldb-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9532179da84a3f23418a532fd86ed616108bdb8560e76bb1000435c96fe8221f
MD5 df3c11496c67a92ce62bf257f5bf798c
BLAKE2b-256 4bd87867fd6cbad04bddcf568f4205623e0bcbf1897422f7ee7a0c3c54e922d9

See more details on using hashes here.

Provenance

The following attestation bundles were made for oceldb-0.9.0-py3-none-any.whl:

Publisher: publish.yml on sebmne/oceldb

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page