Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ A `ToolSpec` describes an end-of-arm tool: TCP offset, 3D mesh descriptors for v
| `tools` | Tool hierarchy, mesh/motion descriptors, enums, `ToolStatus` |
| `joints` | Frozen dataclasses for joint configuration and limits |
| `status` | `StatusBuffer` protocol for real-time state, query result types |
| `recordings` | Immutable joint/tool observations, capture termination reasons and explicit gap/span inspection |
| `results` | `IKResult` and `DryRunResult` protocols with concrete dataclasses; `ObjectTrack` |
| `shapes` | `Shape` kinds, `Physical`, `ShapeWorld`, the wire form and the reporting vocabulary |
| `world` | JSON codec for a `ShapeWorld` -- saved worlds, library entries, import/export |
Expand Down
212 changes: 212 additions & 0 deletions src/waldoctl/recordings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""Controller observations retained independently of any executable trajectory."""

from __future__ import annotations

import math
from dataclasses import dataclass, replace
from typing import Literal

from waldoctl.setup import validate_name

MAX_RECORDING_SAMPLES = 100_000


def _uint(value: int, label: str) -> None:
if type(value) is not int or not 0 <= value <= 0xFFFFFFFFFFFFFFFF:
raise ValueError(f"{label} must be an unsigned 64-bit integer")


def _numbers(values: tuple[float, ...], label: str) -> tuple[float, ...]:
if len(values) > 32 or any(
isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v)
for v in values
):
raise ValueError(f"{label} requires at most 32 finite numbers")
return tuple(float(v) for v in values)


@dataclass(frozen=True)
class RecordedTool:
"""What the controller reported; position and grasp sensing depend on the tool."""

key: str
variant_key: str
positions: tuple[float, ...]
engaged: bool
part_detected: bool
fault_code: int
state: int
channels: tuple[float, ...]

def __post_init__(self) -> None:
for name in (self.key, self.variant_key):
if not isinstance(name, str) or len(name) > 128:
raise ValueError("Recorded tool identifiers must be bounded strings")
positions = _numbers(self.positions, "Tool positions")
if any(not 0 <= value <= 1 for value in positions):
raise ValueError("Recorded tool positions must be normalized to 0–1")
object.__setattr__(self, "positions", positions)
if type(self.engaged) is not bool or type(self.part_detected) is not bool:
raise ValueError("Recorded tool observations must use boolean flags")
_uint(self.fault_code, "Tool fault code")
_uint(self.state, "Tool state")
object.__setattr__(self, "channels", _numbers(self.channels, "Tool channels"))


@dataclass(frozen=True)
class RecordedSample:
seq: int
observed_ns: int
received_ns: int
joints_deg: tuple[float, ...]
tool: RecordedTool | None = None

def __post_init__(self) -> None:
_uint(self.seq, "Publication sequence")
_uint(self.observed_ns, "Controller snapshot time")
_uint(self.received_ns, "Host receipt time")
joints = _numbers(self.joints_deg, "Joint observations")
if not joints:
raise ValueError("A recorded sample needs joint observations")
object.__setattr__(self, "joints_deg", joints)
if self.tool is not None and not isinstance(self.tool, RecordedTool):
raise ValueError("Invalid recorded tool observation")


RecordingEnd = Literal[
"stopped",
"duration_limit",
"sample_limit",
"disconnected",
"session_changed",
"reference_lost",
"disabled",
"tool_changed",
"source_changed",
"invalid_observation",
]


@dataclass(frozen=True)
class RecordingGap:
"""The interval ending at ``sample_index`` needs explicit reconciliation."""

sample_index: int
missing_publications: int
elapsed_s: float


@dataclass(frozen=True)
class Demonstration:
"""Timestamped observations, with no implied authorization to replay them.

``observed_ns`` belongs to the controller clock, ``received_ns`` to the
host clock. Their differences describe cadence and delivery respectively;
subtracting one clock from the other does not measure transport latency.
"""

backend: str
session_id: int
simulator: bool
tcp_transform: tuple[float, ...]
requested_rate_hz: float
gap_threshold_s: float
ended: RecordingEnd
samples: tuple[RecordedSample, ...]

def __post_init__(self) -> None:
validate_name(self.backend)
_uint(self.session_id, "Controller session")
if not self.session_id:
raise ValueError("A demonstration requires controller session metadata")
if type(self.simulator) is not bool:
raise ValueError("The recording source must declare simulator mode")
tcp = _numbers(self.tcp_transform, "TCP transform")
if len(tcp) != 6:
raise ValueError(
"The applied TCP transform requires six values (mm/degrees)"
)
object.__setattr__(self, "tcp_transform", tcp)
for value in (self.requested_rate_hz, self.gap_threshold_s):
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(value)
or value <= 0
):
raise ValueError(
"Recording cadence settings must be positive and finite"
)
if self.ended not in {
"stopped",
"duration_limit",
"sample_limit",
"disconnected",
"session_changed",
"reference_lost",
"disabled",
"tool_changed",
"source_changed",
"invalid_observation",
}:
raise ValueError("Unknown recording termination reason")
samples = tuple(self.samples)
if not 1 <= len(samples) <= MAX_RECORDING_SAMPLES:
raise ValueError(
f"A demonstration requires 1–{MAX_RECORDING_SAMPLES} samples"
)
if any(not isinstance(s, RecordedSample) for s in samples):
raise ValueError("Invalid recording sample")
count = len(samples[0].joints_deg)
for index, sample in enumerate(samples):
if len(sample.joints_deg) != count:
raise ValueError("Joint count changed during the recording")
if index:
previous = samples[index - 1]
# A host clock can tick coarser than the status cadence, so
# consecutive publications may share a receipt time.
if (
sample.seq <= previous.seq
or sample.observed_ns <= previous.observed_ns
or sample.received_ns < previous.received_ns
):
raise ValueError(
"Recording samples must retain their observation order"
)
object.__setattr__(self, "samples", samples)

@property
def duration_s(self) -> float:
return (self.samples[-1].observed_ns - self.samples[0].observed_ns) / 1e9

@property
def observed_rate_hz(self) -> float | None:
return (len(self.samples) - 1) / self.duration_s if self.duration_s else None

@property
def gaps(self) -> tuple[RecordingGap, ...]:
gaps = []
for index in range(1, len(self.samples)):
before, after = self.samples[index - 1], self.samples[index]
missing = after.seq - before.seq - 1
elapsed = (after.observed_ns - before.observed_ns) / 1e9
if missing or elapsed > self.gap_threshold_s:
gaps.append(RecordingGap(index, missing, elapsed))
return tuple(gaps)

def select(self, start: int, stop: int) -> Demonstration:
"""Explicitly select a span, retaining every original timestamp."""
if (
type(start) is not int
or type(stop) is not int
or not 0 <= start < stop <= len(self.samples)
):
raise ValueError("Select a nonempty interval within the recording")
return replace(self, samples=self.samples[start:stop])

def require_continuous(self) -> None:
"""Refuse to infer motion through missing observations."""
if len(self.samples) < 2:
raise ValueError("Replay requires at least two observations")
if self.gaps:
raise ValueError("Select an uninterrupted recording span before replay")
10 changes: 10 additions & 0 deletions src/waldoctl/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ class StatusBuffer(Protocol):
Each field is a numpy array for zero-copy access in the hot path.
"""

session_id: int
"""Nonzero identifier for this controller publisher's lifetime; changes
after restart. Zero means session metadata is unavailable."""
seq: int
"""Status publication sequence within the session. Gaps report missed
publications, including snapshots skipped by a slow consumer."""
mono_time_ns: int
"""Controller monotonic snapshot time in nanoseconds. This is neither
wall-clock time nor a guarantee of each sensor's acquisition time."""

pose: np.ndarray
"""(16,) float64 — flattened 4x4 homogeneous transform."""
angles: np.ndarray
Expand Down
73 changes: 73 additions & 0 deletions tests/test_recordings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Demonstrations keep their observations' order and refuse to bridge gaps."""

import pytest

from waldoctl.recordings import Demonstration, RecordedSample


def _sample(
seq: int, observed_ns: int, received_ns: int | None = None
) -> RecordedSample:
received = observed_ns + 5_000 if received_ns is None else received_ns
return RecordedSample(seq, observed_ns, received, (1.0, 2.0, 3.0, 4.0, 5.0, 6.0))


def _recording(samples: tuple[RecordedSample, ...]) -> Demonstration:
return Demonstration(
backend="parol6",
session_id=7,
simulator=True,
tcp_transform=(0, 0, 0, 0, 0, 0),
requested_rate_hz=50.0,
gap_threshold_s=0.05,
ended="stopped",
samples=samples,
)


def test_a_recording_tolerates_a_coarse_host_clock_but_not_reordered_observations():
# Two publications 20 ms apart landed on the same host clock tick.
shared = _recording(
(
_sample(1, 0, received_ns=100),
_sample(2, 20_000_000, received_ns=100),
_sample(3, 40_000_000, received_ns=200),
)
)
assert shared.gaps == ()
assert shared.duration_s == pytest.approx(0.04)
assert shared.observed_rate_hz == pytest.approx(50.0)
shared.require_continuous()
for reordered in (
(_sample(1, 0), _sample(2, 0)),
(_sample(2, 0), _sample(1, 20_000_000)),
(_sample(1, 0, received_ns=200), _sample(2, 20_000_000, received_ns=100)),
):
with pytest.raises(ValueError, match="order"):
_recording(reordered)

# A dropped publication and a late one are both gaps a replay may not bridge.
gapped = _recording(
(
_sample(1, 0),
_sample(2, 20_000_000),
_sample(4, 40_000_000),
_sample(5, 160_000_000),
_sample(6, 180_000_000),
)
)
assert [(g.sample_index, g.missing_publications) for g in gapped.gaps] == [
(2, 1),
(3, 0),
]
assert gapped.gaps[1].elapsed_s == pytest.approx(0.12)
with pytest.raises(ValueError, match="uninterrupted"):
gapped.require_continuous()
clean = gapped.select(3, 5)
assert clean.samples[0].observed_ns == 160_000_000 and clean.session_id == 7
assert clean.gaps == ()
clean.require_continuous()
with pytest.raises(ValueError, match="at least two"):
gapped.select(0, 1).require_continuous()
with pytest.raises(ValueError, match="nonempty"):
gapped.select(3, 3)
Loading