Added range API, fixed offline processing and DLPak saving

Saving a buffer can now speicfy the buffer used.

Offline processing uses a per signal binary search to hugely reduce
processing time that was mostly spent on an O(n) linear search

API now has range fetching API's for graphs etc
This commit is contained in:
h3cx committed 2026-10-14 21:31:22 +02:00
1 parent 966960ac03
commit 0ae0d90f72
6 files changed
+388 -160

No files matched your search

+109 -7
View File
@@ -111,7 +111,11 @@ recording is available raises `CoreError(code="no_playback_data")`.
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. The operation
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
removes previously recorded derived samples and descriptors, then merges the
new outputs into the processing buffer. Calling it again replaces those
outputs. It does not write an archive or start playback; use `write()` to save
@@ -172,13 +176,111 @@ offload buffer with a manifest. Errors use `"incorrect_mode"`,
Returns whether a recording is active.
### `write(dir: str | Path, filename: str) -> None`
### `write(dir: str | Path, filename: str, *, source: BufferSource = "processing") -> None`
Writes the current **processing** buffer as a `.dlpak` archive; the extension
is appended when absent. After recording, select `"offline"` mode and call
`load_processing_buffer()` before writing. If no processing buffer exists,
this raises `CoreError(code="no_recording")`. Archive failures raise
`DLPakError` with a stable code such as `"write_failed"`.
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
+56 -5
View File
@@ -31,7 +31,7 @@ from dynalab_core.protocols.json.server import JsonServer
from dynalab_core.protocols.packets import ProtocolMessage
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor
from dynalab_core.types import CoreMode, CoreModeState
from dynalab_core.types import BufferSource, CoreMode, CoreModeState
from dynalab_core.values import Value
@@ -499,13 +499,64 @@ class Core:
def unbind_derive_unit(self, unit_id: UUID) -> None:
self._derive_registry.unregister(unit_id)
def write(self, dir: str | Path, filename: str) -> None:
if self._processing_buffer is None:
def _get_archive(self, source: BufferSource) -> DLPak:
if source == "recording":
archive = self._offload_buffer
elif source == "processing":
archive = self._processing_buffer
elif source == "playback":
archive = self._playback_engine._data
else:
raise CoreError(f"Unknown buffer source: {source!r}", code="invalid_source")
if archive is None:
raise CoreError(
"Cannot write recording because no recording is available",
f"No {source} buffer is available",
code="no_recording",
)
self._processing_buffer.write(dir, filename)
return archive
def _get_value_buffer(self, source: BufferSource | None) -> ValueBuffer:
if source is None:
mode_sources: dict[CoreMode, BufferSource] = {
"realtime": "recording",
"offline": "processing",
"playback": "playback",
}
source = mode_sources[self._mode.value]
if source == "recording" and self._recording.is_set():
return self._recording_buffer
archive = self._get_archive(source)
if archive._data is None:
raise CoreError(f"The {source} buffer has no data", code="no_recording")
return archive._data
def get_data_range(
self,
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]]]:
"""Query the current mode's buffer, or an explicitly selected source."""
return self._get_value_buffer(source).get_data_range(
signal_ids, start_ns, end_ns
)
def get_time_range(
self, *, source: BufferSource | None = None
) -> tuple[int, int] | None:
return self._get_value_buffer(source).get_time_range()
def write(
self, dir: str | Path, filename: str, *, source: BufferSource = "processing"
) -> None:
"""Save processing data by default, or explicitly save another buffer."""
if source == "recording" and self._recording.is_set():
raise CoreError(
"Stop recording before saving its archive", code="recording_active"
)
self._get_archive(source).write(dir, filename)
def play(self) -> None:
if self._mode.value != "playback":
+148 -87
View File
@@ -3,11 +3,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
from bisect import bisect_left
from bisect import bisect_left, bisect_right
from collections import defaultdict
import csv
import io
from pathlib import Path
from itertools import groupby
from threading import Lock
from uuid import UUID
@@ -15,99 +15,166 @@ from uuid import UUID
class ValueBuffer:
def __init__(self) -> None:
self._samples: list[tuple[int, UUID, float]] = []
self._samples_by_signal: dict[UUID, list[tuple[int, float]]] = {}
self._unsorted_signals: set[UUID] = set()
self._sorted = True
self._lock = Lock()
def __copy__(self) -> ValueBuffer:
buffer = ValueBuffer()
buffer.merge(self)
return buffer
def _append_no_lock(self, timestamp: int, uuid: UUID, value: float) -> None:
if self._samples and timestamp < self._samples[-1][0]:
self._sorted = False
self._samples.append((timestamp, uuid, value))
signal_samples = self._samples_by_signal.setdefault(uuid, [])
if signal_samples and timestamp < signal_samples[-1][0]:
self._unsorted_signals.add(uuid)
signal_samples.append((timestamp, value))
def append(self, timestamp: int, uuid: UUID, value: float) -> None:
with self._lock:
self._samples.append((timestamp, uuid, value))
self._append_no_lock(timestamp, uuid, value)
def get_samples(self) -> list[tuple[int, UUID, float]]:
"""Return an independent snapshot in the current storage order."""
with self._lock:
return self._samples.copy()
def merge(self, buffer: ValueBuffer) -> None:
samples = buffer.get_samples()
with self._lock:
self._samples += buffer._samples
for timestamp, uuid, value in samples:
self._append_no_lock(timestamp, uuid, value)
def clear(self) -> None:
with self._lock:
self._samples.clear()
self._samples_by_signal.clear()
self._unsorted_signals.clear()
self._sorted = True
def clear_uuid(self, uuid: UUID) -> None:
with self._lock:
self._samples = [sample for sample in self._samples if sample[1] != uuid]
self._samples_by_signal.pop(uuid, None)
self._unsorted_signals.discard(uuid)
def get_signal_ids(self) -> list[UUID]:
with self._lock:
return list({signal_id for _, signal_id, _ in self._samples})
return list(self._samples_by_signal)
def _get_signal_samples_no_lock(self, uuid: UUID) -> list[tuple[int, float]]:
samples = self._samples_by_signal.get(uuid, [])
if uuid in self._unsorted_signals:
samples.sort(key=lambda sample: sample[0])
self._unsorted_signals.remove(uuid)
return samples
def get_nearest_neighbor_no_lock(self, uuid: UUID, timestamp: int) -> float | None:
previous: tuple[int, UUID, float] | None = None
"""Find the nearest sample, preferring the earlier one on a tie.
for sample in self._samples:
if sample[1] != uuid:
continue
The caller must prevent concurrent buffer mutations. Offline processing
uses this method after sorting its private working buffer.
"""
samples = self._get_signal_samples_no_lock(uuid)
if not samples:
return None
if sample[0] >= timestamp:
if previous is None:
return sample[2]
index = bisect_left(samples, timestamp, key=lambda sample: sample[0])
if index == 0:
return samples[0][1]
if index == len(samples):
return samples[-1][1]
d1 = abs(sample[0] - timestamp)
d2 = abs(previous[0] - timestamp)
if d1 < d2:
return sample[2]
else:
return previous[2]
previous = sample
if previous is not None:
return previous[2]
return None
previous = samples[index - 1]
following = samples[index]
if following[0] - timestamp < timestamp - previous[0]:
return following[1]
return previous[1]
def get_nearest_neighbor(self, uuid: UUID, timestamp: int) -> float | None:
previous: tuple[int, UUID, float] | None = None
with self._lock:
return self.get_nearest_neighbor_no_lock(uuid, timestamp)
def get_data_range(
self,
signal_ids: list[UUID] | None = None,
start_ns: int | None = None,
end_ns: int | None = None,
) -> dict[UUID, list[tuple[int, float]]]:
"""Return sorted (timestamp, value) snapshots within inclusive bounds.
Omitted bounds cover all available data; omitted IDs select all signals.
Unknown signals are returned with an empty list. Values are not resampled.
"""
if start_ns is not None and end_ns is not None and start_ns > end_ns:
raise ValueError("start_ns must be less than or equal to end_ns")
with self._lock:
for sample in self._samples:
if sample[1] != uuid:
continue
selected_ids = (
signal_ids if signal_ids is not None else self._samples_by_signal
)
result: dict[UUID, list[tuple[int, float]]] = {}
for uuid in selected_ids:
samples = self._get_signal_samples_no_lock(uuid)
start = (
0
if start_ns is None
else bisect_left(samples, start_ns, key=lambda sample: sample[0])
)
end = (
len(samples)
if end_ns is None
else bisect_right(samples, end_ns, key=lambda sample: sample[0])
)
result[uuid] = samples[start:end]
return result
if sample[0] >= timestamp:
if previous is None:
return sample[2]
d1 = abs(sample[0] - timestamp)
d2 = abs(previous[0] - timestamp)
if d1 < d2:
return sample[2]
else:
return previous[2]
previous = sample
if previous is not None:
return previous[2]
return None
def get_time_range(self) -> tuple[int, int] | None:
"""Return the earliest and latest timestamps, or None for an empty buffer."""
with self._lock:
if not self._samples:
return None
first: list[int] = []
last: list[int] = []
for uuid in self._samples_by_signal:
samples = self._get_signal_samples_no_lock(uuid)
first.append(samples[0][0])
last.append(samples[-1][0])
return min(first), max(last)
def __len__(self) -> int:
with self._lock:
return len(self._samples)
def normalize(self) -> None:
if len(self) == 0:
return
with self._lock:
if not self._samples:
return
minimum = min(sample[0] for sample in self._samples)
self._samples = [
(timestamp - minimum, signal_id, value)
for timestamp, signal_id, value in self._samples
]
self._samples_by_signal = {
uuid: [(timestamp - minimum, value) for timestamp, value in samples]
for uuid, samples in self._samples_by_signal.items()
}
def _sort_no_lock(self) -> None:
if not self._sorted:
self._samples.sort(key=lambda sample: sample[0])
self._sorted = True
for uuid in list(self._unsorted_signals):
self._get_signal_samples_no_lock(uuid)
def sort(self) -> None:
with self._lock:
self._samples.sort(key=lambda sample: sample[0])
self._sort_no_lock()
def import_csv(self, input: str) -> None:
reader = csv.reader(io.StringIO(input, newline=""))
@@ -163,49 +230,43 @@ class ValueBuffer:
imported_samples.sort(key=lambda sample: sample[0])
with self._lock:
self._samples = imported_samples
self._samples = []
self._samples_by_signal = {}
self._unsorted_signals.clear()
self._sorted = True
for timestamp, uuid, value in imported_samples:
self._append_no_lock(timestamp, uuid, value)
def export_csv(self) -> str:
with self._lock:
samples = list(self._samples)
rows: dict[int, dict[UUID, float]] = defaultdict(dict)
signal_ids: set[UUID] = set()
for timestamp_ns, signal_id, value in samples:
rows[timestamp_ns][signal_id] = value
signal_ids.add(signal_id)
ordered_signal_ids = sorted(signal_ids, key=str)
samples = self.get_samples()
samples.sort(key=lambda sample: sample[0])
signal_ids = sorted({sample[1] for sample in samples}, key=str)
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow(["timestamp_ns", *(str(uuid) for uuid in signal_ids)])
writer.writerow(
["timestamp_ns", *(str(signal_id) for signal_id in ordered_signal_ids)]
)
for timestamp, group in groupby(samples, key=lambda sample: sample[0]):
values: dict[UUID, list[float]] = defaultdict(list)
for _, uuid, value in group:
values[uuid].append(value)
for timestamp_ns in sorted(rows):
timestamp_values = rows[timestamp_ns]
writer.writerow(
[
timestamp_ns,
*(
timestamp_values.get(signal_id, "")
for signal_id in ordered_signal_ids
),
]
)
# Repeat a timestamp when a signal has multiple samples there.
# This preserves duplicates without changing the existing CSV layout.
for index in range(max(len(items) for items in values.values())):
writer.writerow(
[
timestamp,
*(
values[uuid][index] if index < len(values[uuid]) else ""
for uuid in signal_ids
),
]
)
return output.getvalue()
def get_samples_from(self, timestamp: int) -> list[tuple[int, UUID, float]]:
with self._lock:
index = bisect_left(
self._samples,
timestamp,
key=lambda sample: sample[0],
)
return self._samples[index:].copy()
self._sort_no_lock()
index = bisect_left(self._samples, timestamp, key=lambda sample: sample[0])
return self._samples[index:]
+17 -56
View File
@@ -3,8 +3,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from copy import copy
from datetime import datetime, timezone
from enum import IntEnum
import inspect
@@ -202,64 +200,27 @@ class DeriveUnit:
def is_running(self) -> bool:
return not self._stopped_event.is_set()
def _process_offline_worker(
self, buffer: ValueBuffer, start: int, end: int
) -> ValueBuffer:
samples = buffer._samples.copy()
output_buffer: ValueBuffer = ValueBuffer()
for sample in samples[start:end]:
if sample[1] in [signal.id for signal in self._input_signals]:
input_args: list[ValueDescriptor] = []
for signal in self._input_signals:
if signal.id == sample[1]:
input_args.append(
ValueDescriptor(sample[1], sample[2], sample[0])
)
else:
input_args.append(
ValueDescriptor(
signal.id,
buffer.get_nearest_neighbor_no_lock(
signal.id, sample[0]
),
sample[0],
)
)
result = self._process_function(*input_args, self._return_signal)
output_buffer.append(
result.timestamp, self._return_signal.id, result.value
)
return output_buffer
def process_offline(self, buffer: ValueBuffer) -> ValueBuffer:
num_samples = len(buffer._samples)
num_chunks = 16
chunk_size = num_samples // num_chunks
# Sort once before the pass so every input lookup is a binary search.
buffer.sort()
input_ids = {signal.id for signal in self._input_signals}
output_buffer = ValueBuffer()
for timestamp, signal_id, value in buffer.get_samples():
if signal_id not in input_ids:
continue
output_buffer: ValueBuffer = ValueBuffer()
with ThreadPoolExecutor(max_workers=num_chunks) as executor:
ranges: list[tuple[int, int]] = []
for i in range(num_chunks):
start = i * chunk_size
if i == num_chunks - 1:
end = num_samples
input_args: list[ValueDescriptor] = []
for signal in self._input_signals:
if signal.id == signal_id:
input_value = value
else:
end = (i + 1) * chunk_size
ranges.append((start, end))
futures = [
executor.submit(self._process_offline_worker, buffer, *range)
for range in ranges
]
results = [future.result() for future in futures]
for result in results:
output_buffer.merge(result)
input_value = buffer.get_nearest_neighbor_no_lock(
signal.id, timestamp
)
input_args.append(ValueDescriptor(signal.id, input_value, timestamp))
result = self._process_function(*input_args, self._return_signal)
output_buffer.append(result.timestamp, self._return_signal.id, result.value)
return output_buffer
+57 -5
View File
@@ -6,8 +6,11 @@ from __future__ import annotations
from copy import copy
from datetime import datetime, timezone
import logging
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Literal, Self
from uuid import UUID
from zipfile import ZIP_DEFLATED, ZipFile
from pydantic import BaseModel, Field
@@ -23,6 +26,7 @@ log = logging.getLogger(__name__)
class RecordManifest(BaseModel):
format_version: Literal[1] = 1
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
signals: list[JsonSignalDescriptor] = Field(default_factory=list)
comments: str = ""
@@ -108,6 +112,36 @@ class DLPak:
def get_all_signal_descriptors(self) -> list[SignalDescriptor]:
return copy([signal.to_pure() for signal in self._manifest.signals])
def get_data_range(
self,
signal_ids: list[UUID] | None = None,
start_ns: int | None = None,
end_ns: int | None = None,
) -> dict[UUID, list[tuple[int, float]]]:
if self._data is None:
raise DLPakError("No data is available", code="no_data")
return self._data.get_data_range(signal_ids, start_ns, end_ns)
def get_time_range(self) -> tuple[int, int] | None:
if self._data is None:
raise DLPakError("No data is available", code="no_data")
return self._data.get_time_range()
@staticmethod
def _validate_contents(data: ValueBuffer, manifest: RecordManifest) -> None:
signal_ids = [signal.id for signal in manifest.signals]
if len(signal_ids) != len(set(signal_ids)):
raise DLPakError(
"The manifest contains duplicate signal IDs", code="invalid_manifest"
)
missing = set(data.get_signal_ids()) - set(signal_ids)
if missing:
raise DLPakError(
"The manifest is missing descriptors for signals: "
+ ", ".join(sorted(str(uuid) for uuid in missing)),
code="invalid_manifest",
)
def read(self, file: str | Path) -> Self:
input_path = Path(file)
@@ -118,6 +152,7 @@ class DLPak:
)
data = ValueBuffer()
data.import_csv(archive.read("data.csv").decode("utf-8"))
self._validate_contents(data, manifest)
except Exception as error:
log.exception(
"Failed to read DLPak archive from %s",
@@ -174,12 +209,26 @@ class DLPak:
output_path = output_dir / file_path
temporary_path: Path | None = None
try:
with ZipFile(output_path, mode="w", compression=ZIP_DEFLATED) as archive:
archive.writestr(
"manifest.json", self._manifest.model_dump_json(indent=2)
)
archive.writestr("data.csv", self._data.export_csv())
self._validate_contents(self._data, self._manifest)
with NamedTemporaryFile(
dir=output_path.parent,
prefix=f".{output_path.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
with ZipFile(
temporary_file, mode="w", compression=ZIP_DEFLATED
) as archive:
archive.writestr(
"manifest.json", self._manifest.model_dump_json(indent=2)
)
archive.writestr("data.csv", self._data.export_csv())
temporary_file.flush()
os.fsync(temporary_file.fileno())
temporary_path.replace(output_path)
except Exception as error:
log.exception(
"Failed to write DLPak archive to %s",
@@ -194,6 +243,9 @@ class DLPak:
f"Could not write DLPak archive {output_path}: {error}",
code="write_failed",
) from error
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
log.info(
"Wrote DLPak archive to %s",
+1
View File
@@ -8,6 +8,7 @@ from typing import Literal
CoreMode = Literal["realtime", "playback", "offline"]
BufferSource = Literal["recording", "processing", "playback"]
@dataclass