Skip to main content

DuckDB-backed OCEL 2.0 storage and lazy query DSL

Project description

oceldb

DuckDB-backed OCEL 2.0 storage and lazy query DSL.

oceldb keeps object-centric event logs on disk as typed Parquet files and exposes a fluent, dataframe-like DSL for filtering, aggregation, sequence analysis, and sublog extraction -- all compiled to SQL and executed lazily by DuckDB.

Features

  • On-disk by default -- logs stay in Parquet; nothing is materialized into Python objects until you ask.
  • Lazy query DSL -- Polars-like API that compiles to SQL. Filter, group, window, and aggregate without writing SQL.
  • OCEL-aware semantics -- first-class support for object states, event occurrences, relation predicates, and sublog extraction.
  • Process discovery -- built-in OC-DFG (Object-Centric Directly-Follows Graph) mining.
  • Inspection helpers -- one-call summaries of types, attributes, time ranges, and relation statistics.
  • Raw SQL escape hatch -- drop into DuckDB SQL whenever you need full control.
  • Minimal dependencies -- only DuckDB.

Installation

pip install oceldb

or with uv:

uv add oceldb

Requires Python 3.12+.

Optional PM4Py interoperability:

pip install "oceldb[pm4py]"

Quick start

from oceldb import OCEL, col, count, has_event
from oceldb.io import convert_sqlite

# Convert an OCEL 2.0 SQLite database to the oceldb Parquet format
convert_sqlite("running-example.sqlite", "running-example", overwrite=True)

with OCEL.read("running-example") as ocel:
    # Find all orders that were paid
    paid_orders = (
        ocel.query
        .objects("order")
        .where(has_event("Pay Order").exists())
    )
    print(paid_orders.count())  # 3

    # Event counts by type
    event_counts = (
        ocel.query
        .events()
        .group_by("ocel_type")
        .agg(count().alias("n"))
        .sort("n", descending=True)
    )
    print(event_counts.collect())

    # Latest state of open orders
    open_orders = (
        ocel.query
        .object_states("order")
        .latest()
        .where(col("status") == "open")
    )
    print(open_orders.count())

Core concepts

OCEL 2.0 (Object-Centric Event Logs) extends traditional flat event logs with multiple interrelated object types. An order fulfillment process, for example, involves orders, items, packages, and payments -- each with their own lifecycle. oceldb embraces this structure natively.

Query roots

ocel.query is the stable entry point. Each root selects a different analytical grain:

Root Row grain Use case
events(...) One row per event Event-level analysis
objects(...) One row per object identity Object selection, relation predicates
object_states(...).latest() Latest reconstructed state per object Current attribute snapshots
object_states(...).as_of(t) State at timestamp t Point-in-time analysis
object_changes(...) One row per raw attribute update Change history
event_occurrences(...) One row per event-object incidence Sequence and process analysis
event_objects() Raw event-to-object relations Low-level joins
object_objects() Raw object-to-object relations Low-level joins

The key distinction:

  • objects(...) -- identity-oriented, no temporal state
  • object_changes(...) -- sparse history rows exactly as stored
  • object_states(...) -- fill-forward reconstructed snapshots for analysis

Fluent query API

Queries are built by chaining methods and execute only on a terminal call:

result = (
    ocel.query
    .events("Place Order", "Pay Order")
    .where(col("amount") > 100)
    .with_columns(month=col("ocel_time").dt.month())
    .group_by("ocel_type", "month")
    .agg(count().alias("n"), avg(col("amount")).alias("avg_amount"))
    .sort("month")
    .collect()                  # execute and return DuckDB relation
)

Chainable methods: where, with_columns, select, group_by, agg, having, sort, unique, limit

Terminal methods: collect, count, exists, scalar, to_sql, ids, to_ocel, write

Optional PM4Py bridge

If you want to hand an oceldb dataset to PM4Py, use the optional interop module:

from oceldb import OCEL
from oceldb.interop import to_pm4py

with OCEL.read("running-example") as ocel:
    pm4py_ocel = to_pm4py(ocel)

This keeps PM4Py out of the core dependency set while still allowing interoperability when you need it.

Column references

Use bare strings for simple references and col(...) for expressions:

# Bare strings in positional arguments
.select("ocel_id", "status")
.group_by("ocel_type")
.sort("ocel_time")

# col() for expressions
.where(col("status") == "open")
.with_columns(upper_name=col("name").str.upper())

Relation predicates

Filter objects or events based on their relationships:

from oceldb import has_event, has_object, cooccurs_with, linked

# Orders that have a "Pay Order" event
ocel.query.objects("order").where(has_event("Pay Order").exists())

# Orders co-occurring with at least 3 items
ocel.query.objects("order").where(cooccurs_with("item").count() >= 3)

# Events involving a "customer" object
ocel.query.events().where(has_object("customer").exists())

# Objects linked to another object type
ocel.query.objects("order").where(linked("package").exists())

Sequence analysis

event_occurrences(...) combined with window functions enables process analysis:

from oceldb import col, row_number

timeline = (
    ocel.query
    .event_occurrences("order")
    .with_columns(
        seq=row_number().over(
            partition_by="ocel_object_id",
            order_by=("ocel_event_time", "ocel_event_id"),
        ),
        previous=col("ocel_event_type").lag().over(
            partition_by="ocel_object_id",
            order_by=("ocel_event_time", "ocel_event_id"),
        ),
        next=col("ocel_event_type").lead().over(
            partition_by="ocel_object_id",
            order_by=("ocel_event_time", "ocel_event_id"),
        ),
    )
    .select("ocel_object_id", "seq", "previous", "ocel_event_type", "next")
    .sort("ocel_object_id", "seq")
)

Sublog extraction

Identity-preserving query roots (events, objects, object_states) can materialize filtered sublogs:

# Write a sublog containing only paid orders and their related events
(
    ocel.query
    .objects("order")
    .where(has_event("Pay Order").exists())
    .write("paid-orders-sublog", overwrite=True)
)

Inspection

Direct structural facts about a log, without derived analytics:

from oceldb.inspect import (
    overview, event_types, object_types,
    event_attributes, object_attributes,
    table_counts, time_range,
)

with OCEL.read("my-log") as ocel:
    info = overview(ocel)
    print(info.event_count, info.object_count)

    print(event_types(ocel))          # ["Place Order", "Pay Order", ...]
    print(object_types(ocel))         # ["order", "item", "package", ...]
    print(event_attributes(ocel))     # {"Place Order": {"amount": "DOUBLE", ...}}
    print(table_counts(ocel))         # TableCounts(event=120, object=45, ...)
    print(time_range(ocel))           # TimeRange(min=..., max=...)

Discovery

Derived analytical artifacts mined from the log:

from oceldb.discovery import ocdfg

with OCEL.read("my-log") as ocel:
    dfg = ocdfg(ocel, "order")

    for node in dfg.nodes:
        print(f"{node.activity}: {node.count} occurrences")

    for edge in dfg.edges:
        print(f"{edge.source} -> {edge.target}: {edge.count}x, "
              f"mean {edge.mean_duration_seconds:.0f}s")

Raw SQL

When the DSL isn't enough, drop into DuckDB SQL directly:

with OCEL.read("my-log") as ocel:
    result = ocel.sql("SELECT ocel_type, COUNT(*) FROM event GROUP BY 1")
    print(result.fetchall())

Expression reference

Category Functions
Column col(name), lit(value)
Comparison ==, !=, <, >, <=, >=, .is_null(), .not_null(), .is_in(...)
Logic &, |, ~
Arithmetic +, -, *, /
Aggregation count(), count_distinct(expr), sum_(expr), avg(expr), min_(expr), max_(expr)
Functions abs_(expr), coalesce(*exprs), round_(expr, n)
String .str.upper(), .str.lower(), .str.contains(pat), .str.starts_with(s), .str.len()
Datetime .dt.year(), .dt.month(), .dt.day(), .dt.hour()
Conditional when(pred).then(val).otherwise(val)
Window row_number(), .lag(), .lead() -- via .over(partition_by=..., order_by=...)
Sorting asc(expr), desc(expr)
Relations has_event(type), has_object(type), cooccurs_with(type), linked(type)
Alias .alias(name)

Storage layout

oceldb uses a canonical directory format:

my-log/
  manifest.json           # schema, provenance, storage metadata
  event.parquet           # events (ocel_id, ocel_type, ocel_time, + custom attrs)
  object.parquet          # object identities (ocel_id, ocel_type)
  object_change.parquet   # raw sparse object-history rows (ocel_id, ocel_type, ocel_time, ocel_changed_field, + custom attrs)
  event_object.parquet    # event-to-object relations (ocel_event_id, ocel_object_id, ocel_qualifier)
  object_object.parquet   # object-to-object relations (ocel_source_id, ocel_target_id, ocel_qualifier)

Convert from OCEL 2.0 SQLite with:

from oceldb.io import convert_sqlite

convert_sqlite("source.sqlite", "target-directory", overwrite=True)

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.4.0.tar.gz (1.5 MB view details)

Uploaded Source

Built Distribution

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

oceldb-0.4.0-py3-none-any.whl (63.0 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for oceldb-0.4.0.tar.gz
Algorithm Hash digest
SHA256 5f0ba209c433d699534d92280230d085c1ed7b7c688e591b1b54a17007e5ae1d
MD5 548f6da3ee59ce7a5c9c503ebf148db6
BLAKE2b-256 1093dd3085b3d1913335d534d0450bc7cafea6059f374a538076a582d487d0b9

See more details on using hashes here.

Provenance

The following attestation bundles were made for oceldb-0.4.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.4.0-py3-none-any.whl.

File metadata

  • Download URL: oceldb-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 63.0 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.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2bef4c2dd49b55bbff945d0c8c8be671672148dde3159ce534cf0c02bd63624a
MD5 849987bc68cccf2e8b72de4f2f5e641f
BLAKE2b-256 ec82f57b5f06400d4505a1842b359ef2288f1b4e63e0e3ed4944e38f979d4f7a

See more details on using hashes here.

Provenance

The following attestation bundles were made for oceldb-0.4.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