Skip to content

Python

crates/lefsim-py wraps the core as a Python extension with pyo3, and python/ is the lefsim package built from it: one abi3 wheel for Python 3.9 and later. The classes mirror the Rust types. Results come back as Arrow data through the Arrow PyCapsule Interface, so pyarrow, polars and duckdb read them directly, without copying, and the package itself has no Python dependencies.

A run

import lefsim
import polars as pl

lattice = lefsim.Lattice(1000, {"site": [300, 700], "captures_from": ["right", "left"], "strength": 0.9})
world = lefsim.populate(lattice, 10, seed=1)  # or lefsim.World(lattice, table_of_lefs)
model = lefsim.Model(world, lefsim.Rates(step=1.0, respawn=1e-3, respawn_blocked=1e-2, respawn_captured=1e-3))
sim = lefsim.Sim(model, seed=42)

sim.run_until(100.0)  # advance only
rec = sim.record_until(500.0, dt=1.0, events=True)  # advance and record

snaps = pl.DataFrame(rec.snapshots)  # time, lef_id, left_pos, right_pos, left/right_mobility
deltas = pl.DataFrame(rec.events)  # time, leg_id, lef_id, pos, mobility, action
pl.DataFrame(sim.state())  # the current state, one row per LEF

Tables in and out

A Lattice takes its CTCFs, and a World its LEFs, as any Arrow table (pyarrow, polars, pandas) or a dict of columns: site, captures_from and optional strength for CTCFs (single values apply to all), and left_pos, right_pos and optional left_mobility/right_mobility for LEFs. lattice.to_arrow() and world.to_arrow() give the tables back, so lefsim.World(lattice, sim.state()) works too.

Snapshots, sim.state() and world.to_arrow() are all the same LEF table (snapshots and states add time). Pass layout="leg" for one row per leg instead, the same row shape as the event log.

Saving and forking

sim.model() returns the current state as a Model, which saves with model.to_json() and loads with lefsim.Model.from_json(s) (or to_dict() / from_dict() for plain Python data). The model types also pickle. copy.copy(sim) forks a run: the copy continues exactly as the original would.

Long runs

Runs release the GIL and respond to Ctrl-C. record_snapshot_chunk and record_event_chunk record in bounded pieces. For runs too large to hold in memory, sim.stream(t_end, dt=...) turns the simulation into a lazy Arrow stream that runs as it's read:

sim.stream(1e7, events=True, keyframes=True).to_parquet("events.parquet")  # consumes `sim`

to_parquet writes one row group per keyframed chunk (zstd by default, with the event times byte-stream-split, which makes files about a third smaller), and records where the run came from in the file's metadata. Streams carry the same metadata in their Arrow schema.

Replaying

r = lefsim.Replayer.from_parquet("events.parquet")  # reads only the footer
r.world_at(5_000.0)  # reads one row group: a World to start a new run from
pl.DataFrame(r.snapshots(dt=10.0, t_start=4_000.0, t_end=6_000.0))  # a grid, as record_until would have

from_parquet takes the lattice and end time from the file's metadata, and reads only the row groups each call needs, so a file of any size opens instantly. Other Arrow sources load whole with Replayer.from_arrow(source, lattice, end=...), which accepts any batch boundaries.

Effective distance

See Effective distance for the rules. World.effective_distance and effective_distances work on one state, and Replayer.effective_distances on a time grid.

The tour notebook

python/notebooks/lefsim_tour.ipynb walks through a model with a few TADs: leg trajectories, an occupancy profile, where loops anchor, loop sizes, the event log, a streamed run, saving, replaying and branching. Run it with the python/ venv as its kernel.