# DynaLab Core API `Core` runs a DynaLab Core instance: it accepts connector data, retains live values, records data, prepares DLPak archives, plays recordings, and manages derive units. Connector authors should also read the [protocol guide](Protocol.md). > [!IMPORTANT] > **Packet model change.** The default packet and signal classes are lightweight > `@dataclass(slots=True)` objects. They intentionally do not validate or coerce > input at construction time. Pydantic models are retained only at a JSON > boundary and are named with the `Json` prefix, such as `JsonHeartbeat` and > `JsonConnectorHello`. Use the `Json*` classes with JSON-over-TCP and convert > with `to_pure()`; use the unprefixed classes inside Core, connectors, and > derive code. See [Packet representations](Protocol.md#packet-representations). > [!WARNING] > DynaLab Core is in alpha. Its API and protocol can change between releases. ## Quick start ```python from dynalab_core import Core from dynalab_core.config import CoreConfig core = Core(CoreConfig(host="127.0.0.1", port=58763)) try: core.start() core.wait() finally: core.stop() ``` Constructing `Core` starts its internal workers. `start()` starts the TCP server. Always call `stop()` during shutdown; a stopped core cannot be started again. ## Configuration `CoreConfig` is a Pydantic configuration model in `dynalab_core.config`. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `host` | `str` | `"127.0.0.1"` | TCP bind address. | | `port` | `int` | `58763` | TCP bind port; validated from 0 through 65535. | | `thread_debug` | `bool` | `False` | Enables periodic per-thread CPU debug logging. | | `derive_queue_capacity` | `int` | `16384` | Maximum queued requests in the derive router and each derive unit; minimum 1. | | `playback_mode` | `"reprocess" \| "read"` | `"reprocess"` | Whether playback recalculates bound derived signals or reads their recorded values. Offline `reprocess()` requires `"read"`. | `CoreConfig.bind_str() -> str` returns `"host:port"`. ## Lifecycle ### `Core(config: CoreConfig)` Creates a core, its input, playback, and derive workers, plus the JSON/TCP server object. It does not bind the network port until `start()`. ### `start() -> None` Starts the configured JSON/TCP server. It may be called only once, while Core is initialised; otherwise it raises `CoreError(code="invalid_state")`. A start timeout or bind failure raises `JsonServerError` with code `"start_timeout"` or `"startup_failed"`. ### `wait(timeout: float | None = None) -> None` Blocks until `stop()` is called or the optional timeout elapses. ### `stop() -> bool` Stops the server, connector registry, derive registry, input worker, and playback worker. It also waits up to five seconds for an active data operation; an operation still running makes shutdown incomplete. A processing operation that finishes after stopping begins cannot commit a new processing buffer. Returns `True` only when all required components stop within their time limits; it returns `False` if shutdown is incomplete. Calling it on a constructed Core before `start()`, or calling it again after shutdown, is supported. ## Modes and buffers `CoreMode` is `"realtime" | "playback" | "offline"`; new instances start in `"realtime"`. `set_mode()` clears cached live values and cannot run during an active recording (`CoreError(code="recording_active")`). | Mode | Source | Purpose | | --- | --- | --- | | `"realtime"` | Connected connectors | Live values and recording. | | `"playback"` | Loaded DLPak | Time-based replay. | | `"offline"` | Recorded or loaded DLPak | Prepare a processing buffer without replaying it. | Endpoints remain connected in non-realtime modes, but only realtime connector values are routed into Core. ### `set_mode(mode: CoreMode) -> None` / `get_mode() -> CoreMode` Sets or returns the current mode. `CoreMode` is a type contract; callers must pass one of the three values above. Leaving playback mode automatically pauses it at its current position. Returning to playback mode does not automatically resume it; call `play()` when ready. ### `load_playback_engine(input: Path | None = None, *, background: bool = False) -> UUID | None` Available only in `"playback"` mode (`CoreError(code="invalid_mode")`). Pass a `.dlpak` path, or omit `input` to load the current processing buffer. With no processing buffer, omitting it raises `CoreError(code="no_playback_data")`. Loading takes a deep copy of the archive, resets the playback position to zero, and clears cached live values. It does not start playback; use `play()`. ### `load_processing_buffer(input: Path | None = None, *, background: bool = False) -> UUID | None` Available only in `"offline"` mode (`CoreError(code="invalid_mode")`). Pass a `.dlpak` path to load it, or omit `input` to turn the completed recording into a processing buffer and create its manifest. Omitting `input` before a recording is available raises `CoreError(code="no_playback_data")`. Completed recordings, processing buffers, and loaded playback archives have independent sample containers, indexes, locks, and manifest metadata. Loading a processing buffer deep-copies the completed recording. Loading playback deep-copies its input archive. Reprocessing, editing metadata, or beginning a new recording therefore does not mutate earlier snapshots. `DLPak.set_data()` also takes an independent data snapshot; `copy()` and `deepcopy()` of a DLPak copy both its data and its nested manifest metadata. ### `reprocess(*, background: bool = False) -> UUID | None` Recalculates derived signals in the current processing buffer using the bound derive units. Call it in `"offline"` mode after `load_processing_buffer()`, with `CoreConfig(playback_mode="read")`. Units with source inputs run first; units that depend on their derived outputs run in dependency order. Each unit runs in sample order using per-signal binary searches instead of full-buffer scans. For other inputs it selects the nearest recorded value, preferring the earlier sample when distances tie. This retains the previous offline alignment policy, including selection of future samples. The operation works on a deep copy, removes its previously recorded derived samples and descriptors, and merges the new outputs. It replaces the processing buffer only after all units finish successfully. Calling it again replaces those outputs. It does not write an archive or start playback; use `write()` to save the result, or switch to `"playback"` mode, call `load_playback_engine()`, and then `play()`. The mode, configuration, and buffer checks raise `CoreError` with codes `"invalid_mode"`, `"invalid_config"`, and `"empty_buffer"`, respectively. Missing inputs raise `DeriveError(code="missing_inputs")`, cycles raise `DeriveError(code="dependency_cycle")`, and a unit that produces no values raises `DeriveError(code="no_values")`. Errors from a derive function propagate to the synchronous caller or are stored in the background operation status. Failure leaves the original processing buffer, including its prior derived results and metadata, unchanged. ## Signals and live values `SignalDescriptor` is the lightweight internal signal type in `dynalab_core.protocols.packets.handshake`. Its fields are `id`, `name`, `type` (`"number"` or `"binary"`), `min_value`, `max_value`, `unit`, `timeout_ms` (default `2000`), and `origin` (`"source"` or `"derived"`, default `"source"`). Use `JsonSignalDescriptor` only when serializing a descriptor. ### `get_signal_descriptor(signal_id: UUID) -> SignalDescriptor | None` Returns a copy of the matching descriptor or `None`. Realtime descriptors come from connectors and bound derive units. Playback descriptors come from the loaded archive; derived descriptors are recalculated only when `playback_mode="reprocess"`. ### `get_all_signal_descriptors() -> list[SignalDescriptor]` Returns descriptors available in the current mode. In `"read"` playback mode, recorded derived descriptors are included; in `"reprocess"` mode, bound derive units supply them instead. ### `get_live_value(signal_id: UUID) -> float | None` Returns the most recent non-stale value for a signal, or `None` when unknown, not yet received, or past the signal's `timeout_ms`. ### `get_all_live_values() -> dict[UUID, float]` Returns a snapshot of current non-stale values. Signals without a live value are omitted. Ingress timestamps use `time.monotonic_ns()`; see the protocol guide for details. ## Recording and DLPak archives ### `start_recording() -> None` / `stop_recording() -> None` Recording is available only in `"realtime"` mode. Start and stop insert markers into Core's input queue and wait for the input worker to acknowledge them. Samples queued before the start marker are excluded; samples between the start and stop markers are included. This is a boundary at Core's input queue: packets still buffered in an endpoint and derive results not yet queued when the stop marker is inserted are outside the recording. There is no fixed sleep or guess about when queued samples have finished. Starting creates a new in-memory buffer. Stopping normalizes and sorts the completed samples and creates an isolated offload archive. Signal descriptors are retained throughout recording, including descriptors attached to incoming values. A connector can join after recording starts and disconnect before its queued samples are ingested without losing their metadata. Errors use `"incorrect_mode"`, `"recording_active"`, or `"not_recording"` as appropriate. A failed input worker raises `CoreError(code="worker_failed")`; a full queue that cannot accept a control marker within five seconds raises `CoreError(code="input_overload")`. ### `get_recording_state() -> bool` Returns whether a recording is active. ### `write(dir: str | Path, filename: str, *, source: BufferSource = "processing", background: bool = False) -> UUID | None` Writes the selected buffer as a `.dlpak` archive; the extension is appended when absent. `BufferSource` is `"recording" | "processing" | "playback"`, defined in `dynalab_core.types`. The default remains `"processing"` for compatibility. Use `source="recording"` to save a completed recording directly, without switching modes or loading the processing buffer. Stop recording first; saving an active recording raises `CoreError(code="recording_active")`. `source="playback"` saves the loaded archive, not the current playback position or newly recalculated live values. Saving does not change mode or load data. An unavailable buffer raises `CoreError(code="no_recording")`. Archive failures raise `DLPakError(code="write_failed")`. Writes use a temporary file in the destination directory and replace the destination only after the archive has been completed successfully. The destination directory must already exist. ```python from pathlib import Path core.start_recording() # Receive connector data, then finish and save it. core.stop_recording() core.write("recordings", "run-001", source="recording") core.set_mode("offline") core.load_processing_buffer(Path("recordings/run-001.dlpak")) ``` ### DLPak format and standalone access `DLPak` in `dynalab_core.dlpak` can also be used without Core: ```python from pathlib import Path from dynalab_core.dlpak import DLPak archive = DLPak().read(Path("recordings/run-001.dlpak")) series = archive.get_data_range(start_ns=0, end_ns=1_000_000_000) archive.write("recordings", "run-001-copy") ``` Archives are ZIP files containing `manifest.json` and `data.csv`. The manifest contains `format_version` (currently `1`), a UTC recording timestamp, signal descriptors, and comments. Older manifests without `format_version` are read as version 1; unsupported versions are rejected. Signal IDs in the manifest must be unique, and every signal present in the CSV must have a descriptor. Descriptors without samples are allowed. Invalid archives raise `DLPakError(code="read_failed")`, with the underlying cause retained. The CSV header is `timestamp_ns` followed by signal UUID columns. Empty cells represent absent samples. Rows are ordered by timestamp. Repeated timestamps preserve multiple values for the same signal; samples are not collapsed. ## Buffer queries for plotting ### `get_data_range(signal_ids: list[UUID] | None = None, start_ns: int | None = None, end_ns: int | None = None, *, source: BufferSource | None = None) -> dict[UUID, list[tuple[int, float]]]` Returns an independent snapshot grouped by signal ID. Each list contains `(timestamp_ns, value)` pairs in timestamp order, including duplicate timestamps. Both bounds are inclusive. Omit a bound for an open-ended range, or omit both for all samples. Omit `signal_ids` to select every signal; an unknown requested ID maps to an empty list. An inverted range raises `ValueError`. Data is returned at its original resolution without resampling or downsampling. By default, the selected buffer follows the current mode: | Mode | Queried buffer | | --- | --- | | `"realtime"` | Active recording, or the latest completed recording when not recording. | | `"offline"` | Loaded processing buffer. | | `"playback"` | Loaded playback archive, including its recorded derived samples. | Pass `source="recording"`, `"processing"`, or `"playback"` to query a specific buffer without changing modes. An unavailable buffer raises `CoreError(code="no_recording")`; an available empty buffer returns empty data. No history is collected outside recordings. Playback queries access the loaded archive, not newly recalculated live derived values. Completed recordings use nanoseconds relative to their first sample. Active recordings still contain ingress `time.monotonic_ns()` timestamps. Imported archives retain their stored timestamps. Bounds and returned timestamps always use the selected buffer's stored time base; stopping a recording normalizes it. ```python bounds = core.get_time_range() series = core.get_data_range( [rpm_signal_id, torque_signal_id], start_ns=1_000_000_000, end_ns=2_000_000_000, ) # series[rpm_signal_id] is a list of (timestamp_ns, value) pairs. ``` ### `get_time_range(*, source: BufferSource | None = None) -> tuple[int, int] | None` Returns the minimum and maximum stored timestamps for the same buffer selection as `get_data_range()`. An empty buffer returns `None`; a single sample returns its timestamp for both bounds. `DLPak` and `ValueBuffer` expose the same `get_data_range()` and `get_time_range()` methods without the `source` argument. Buffer mutations maintain per-signal indexes; out-of-order signal samples are sorted before querying. Once sorted, a range query uses binary search plus copying the selected samples, rather than scanning unrelated signals. ## Playback ### `play() -> None` / `pause() -> None` Start or resume, and pause, a loaded playback engine. Both require `"playback"` mode (`CoreError(code="invalid_mode")`). `play()` before loading data raises `PlaybackError(code="no_data")`. Pause preserves the current position and resume continues with the next pending sample. Controls interrupt waits across long sample gaps; a full input queue does not block pause or stop. Playback returns to timestamp zero after its last playable sample, and a later `play()` restarts it. An empty archive completes without emitting samples. Playback timing starts at the archive's first stored timestamp, even if it is nonzero. The loaded archive itself keeps its original timestamps for range queries and saving. In reprocess mode, recorded derived values are skipped and bound derive units calculate their replacements. ### `seek(timestamp_ns: int) -> None` Sets the playback position relative to the recording's first sample, in nanoseconds. Works while playing or paused and preserves that state. The next emitted sample is at or after the requested position, including all samples exactly at that timestamp. Positions past the recording's end are clamped to its duration. Seeking clears cached live values; fresh values arrive when playback runs. Queued samples and derive results from before the seek are discarded rather than entering the new timeline. Requires `"playback"` mode; a negative timestamp raises `PlaybackError(code="invalid_timestamp")`, and seeking before loading an archive raises `PlaybackError(code="no_data")`. ### `get_playback_state() -> bool` Returns `True` while playback is running, including waits between samples. ### `get_playback_position() -> int` Returns the current playback position in nanoseconds relative to the archive's first sample. The position advances while playing, remains fixed while paused, and resets to zero when playback finishes or a new archive is loaded. ## Derived signals ### `bind_derive_unit(dlunit: DLUnit | None = None, file: Path | None = None) -> UUID` Binds an in-memory `DLUnit` or loads one from a `.dlunit` path, then returns its ID. Provide exactly one source. Missing or competing sources raise `CoreError(code="bind_derive_failed")`; invalid source or function shape raises `DeriveError` (for example `"invalid_source"` or `"invalid_signature"`). Derive source operates on internal `ValueDescriptor` and `SignalDescriptor` dataclasses, not their `Json*` equivalents. Source is executed with `exec()`; only load units from trusted sources. ### `validate_derive_units() -> None` Checks the configured graph against signals available in the current mode. Missing inputs (including archive signals with no samples when reprocessing) raise `DeriveError(code="missing_inputs")`; dependency cycles raise `"dependency_cycle"`; duplicate outputs or outputs colliding with sources raise `"duplicate_output"`. Outputs must have `origin="derived"`, otherwise validation raises `"invalid_origin"`. A derive function must return a `ValueDescriptor` whose `signal_id` is its configured output, or it raises `"invalid_result"`. Structural validation runs before a unit is registered, so cycles and duplicate outputs cannot begin realtime execution. Units can be bound before their sources or upstream units connect; missing inputs are checked before execution. Realtime routing revalidates when connectors, bound units, mode, or loaded data change. An incomplete graph blocks derive routing until its dependencies become available, and reports the reason in the routing worker's status. It does not stop raw acquisition or recording. Offline reprocessing validates the entire graph and sample availability before calling any derive function. Connector signal IDs must also be unique across connectors and derive outputs; registration rejects collisions with `ConnectorError(code="duplicate_signal")`. Connector descriptors must have `origin="source"`. ### Saving DLUnit archives `DLUnit.set_data(source: str)` accepts the Python source text. `DLUnit.write(dir, filename)` writes `manifest.json` and the UTF-8 source as `unit.py` inside a ZIP archive, appending `.dlunit` when needed. `DLUnit().read(path)` restores both the manifest and source, ready to pass to `bind_derive_unit()`. Saving without source or a manifest raises `DLUnitError` with code `"no_data"` or `"no_manifest"`, respectively. Writes use a temporary file followed by atomic replacement, so a failed write preserves an existing unit archive. ### `unbind_derive_unit(unit_id: UUID) -> None` Stops and removes a bound derive unit. An unknown ID has no effect. ## Background operations and progress `load_processing_buffer()`, `load_playback_engine()`, `write()`, and `reprocess()` remain synchronous by default: they return `None` on success and raise errors on failure. Pass `background=True` to return an operation UUID immediately. Only one data operation can run at a time. Data operations are rejected while recording; mode changes, recording controls, derive binding/unbinding, and playback controls are rejected while a data operation is active, with `CoreError(code="operation_active")`. Queries and status polling remain available. ### `get_operation(operation_id: UUID | None = None) -> OperationStatus | None` Returns an immutable snapshot from `dynalab_core.operations`. With no ID, it returns the most recently started operation, or `None` before any operation. Fields are `id`, `name`, `state` (`"running"`, `"completed"`, or `"failed"`), `completed`, `total`, `message`, `error_code`, and `error_message`. The `progress` property is the fraction `completed / total`. Load/save progress reports coarse archive stages; reprocessing reports completed derive units, rather than a predicted remaining time. Completion is determined by `state`, not the progress fraction alone. The latest 100 operations are retained. ### `wait_operation(operation_id: UUID, timeout: float | None = None) -> OperationStatus` Waits for completion or the timeout. On timeout, returns the current running status. On failure, raises the original exception; the failed status remains available through `get_operation()`. An unknown or expired ID raises `CoreError(code="unknown_operation")`. ```python operation_id = core.reprocess(background=True) status = core.get_operation(operation_id) # Poll from the GUI timer. if status.state == "failed": show_error(status.error_message) # Outside the GUI event loop, wait and propagate any error: core.wait_operation(operation_id) ``` ## Connector, derive, and worker status ### `get_state() -> str` Returns Core's lifecycle state: `"initd"`, `"started"`, `"stopping"`, `"stopped"`, or `"stop_incomplete"` after construction. ### `get_connectors() -> list[ConnectorStatus]` Returns immutable snapshots of registered endpoints: `id`, `name`, `version`, `signal_ids`, and `state` (`"connected"`, `"stopping"`, or `"failed"`). Endpoints are removed after disconnect cleanup; a GUI can compare snapshots to detect connection and disconnection. ### `get_derive_units() -> list[DeriveUnitStatus]` Returns each bound unit's `id`, `input_signal_ids`, `output_signal_id`, and `worker` status. Failed units retain their error until unbound; unbind and rebind a repaired unit to start a new worker. ### `get_worker_statuses() -> list[WorkerStatus]` Returns the input, JSON server, playback, derive router, and individual derive workers. Each status contains `name`, `state` (`"running"`, `"stopped"`, `"failed"`, or `"blocked"`), `error`, `queue_size`, `queue_capacity`, and `dropped_samples`. A blocked routing worker reports missing dependencies. A server not started yet is stopped. Worker status describes worker health; use `get_playback_state()` to distinguish playing from paused. The status dataclasses are defined in `dynalab_core.types`. ### Derive overload policy `CoreConfig.derive_queue_capacity` bounds both the routing queue and every unit's input queue. Full derive queues drop the newest request, increment their `dropped_samples` counter, and log a warning on the first drop. They do not block raw ingestion or discard raw samples already accepted into Core's recording buffer. Unit output waits for space in Core's bounded input queue and remains interruptible during shutdown. Core's input queue has its own existing limit; connector input overload disconnects the affected endpoint and is logged. Offline processing does not use these derive queues and does not drop samples. ## Exceptions Expected library errors inherit from `DynaLabError` in `dynalab_core.errors` and expose a stable `code` attribute. Catch a specific subsystem class for recovery, or `DynaLabError` at an application boundary. Operating-system and validation causes are retained through `error.__cause__` where applicable. | Exception | Area | | --- | --- | | `CoreError` | Lifecycle, modes, recording, buffer preparation, derive binding. | | `DLPakError` | DLPak archive read/write and completeness. | | `DLUnitError` | DLUnit archive read/write and completeness. | | `DeriveError` | Derive source, signature, arguments, registration. | | `PlaybackError` | Playback data, state, and timestamps. | | `ConnectorError` | Connector queues and registration. | | `JsonServerError` | JSON/TCP server startup and timeouts. |