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

+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",