Files
csaxs_bec/tests/tests_devices/test_canon_ring_buffer.py
T
menzelandClaude Opus 4.8 2cbe3737ce
CI for csaxs_bec / test (push) Successful in 1m31s
feat(canon): add Canon CR-N300 PTZ camera device (Phase 1)
Custom (non-EPICS) ophyd device for the Canon CR-N300 PTZ remote camera.

Phase 1 (implemented): standalone control that works with the imaging stack
absent. PTZ/zoom/focus exposed as positioners (move()/set() -> status, stop(),
spec limits: pan +/-170 deg, tilt -30..+100 deg, 20x zoom); camera parameters
(exposure/iris, gain, white balance, focus mode, presets) via read()/describe()
with read-back verification; Manual + Continuous/Face/Tracking AF focus modes.

Control plane (transport/, pure stdlib, no ophyd):
- CameraTransport ABC; XCTransport implements the Canon XC Control Protocol
  (session open/claim/yield, HTTP/CGI) with an injectable opener; FakeTransport
  is a full in-memory simulator for offline mode and tests. Spec-defined CGI
  paths/params/axis encodings (BPE-7216-005) are isolated and flagged for
  hardware verification.

Imaging plane (acquisition/, pure stdlib, no ophyd, not a control dependency):
- FrameRingBuffer (bounded, thread-safe, stores compressed frames) and a
  persistent, fail-soft, instrumented StreamWorker. Phase-2 grab API
  (get_latest/get_latest_n/get_nearest, decode-on-grab) seamed in the device.

Credentials come from env vars (CANON_<NAME>_USER/_PASSWORD), never YAML/Redis.

Adds example device config (real + simulation), a README, and pytest coverage
(transport mocked, ring buffer, stream worker, device logic) — no hardware
required. No new third-party dependencies (Phase-2 decode uses cv2, already a
csaxs_bec dependency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:20:01 +02:00

84 lines
2.4 KiB
Python

"""Tests for the Canon acquisition ring buffer (pure stdlib, no hardware)."""
import threading
import pytest
from csaxs_bec.devices.canon.acquisition.ring_buffer import Frame, FrameRingBuffer
def test_rejects_bad_maxlen():
with pytest.raises(ValueError):
FrameRingBuffer(0)
def test_put_stamps_sequence_and_timestamps():
clock = iter([1.0, 2.0, 3.0])
wall = iter([100.0, 200.0, 300.0])
buf = FrameRingBuffer(
8, source_id="cam", time_func=lambda: next(clock), wall_time_func=lambda: next(wall)
)
f0 = buf.put(b"a")
f1 = buf.put(b"bb")
assert isinstance(f0, Frame)
assert (f0.seq, f0.monotonic_ts, f0.wall_ts, f0.source_id) == (0, 1.0, 100.0, "cam")
assert (f1.seq, f1.nbytes) == (1, 2)
def test_get_latest_empty_and_filled():
buf = FrameRingBuffer(4)
assert buf.get_latest() is None
buf.put(b"x")
buf.put(b"y")
assert buf.get_latest().data == b"y"
def test_overwrites_oldest_and_counts_drops():
buf = FrameRingBuffer(3)
for i in range(5):
buf.put(bytes([i]))
snap = buf.snapshot()
assert [f.data for f in snap] == [b"\x02", b"\x03", b"\x04"] # oldest two evicted
stats = buf.stats()
assert stats["size"] == 3
assert stats["total_received"] == 5
assert stats["dropped_unread"] == 2
def test_get_latest_n():
buf = FrameRingBuffer(5)
for i in range(4):
buf.put(bytes([i]))
assert [f.data for f in buf.get_latest_n(2)] == [b"\x02", b"\x03"]
assert len(buf.get_latest_n(10)) == 4 # capped at available
assert buf.get_latest_n(0) == []
def test_get_nearest_wall_and_monotonic():
mono = iter([1.0, 2.0, 3.0])
wall = iter([10.0, 20.0, 30.0])
buf = FrameRingBuffer(8, time_func=lambda: next(mono), wall_time_func=lambda: next(wall))
buf.put(b"a")
buf.put(b"b")
buf.put(b"c")
assert buf.get_nearest(19.0).data == b"b" # wall clock default
assert buf.get_nearest(2.9, clock="monotonic").data == b"c"
empty = FrameRingBuffer(2)
assert empty.get_nearest(0.0) is None
def test_thread_safety_under_concurrent_writers():
buf = FrameRingBuffer(1000)
def worker(tag):
for i in range(200):
buf.put(f"{tag}-{i}".encode())
threads = [threading.Thread(target=worker, args=(t,)) for t in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert buf.stats()["total_received"] == 1000
assert len(buf) == 1000