Fix repeat ownership and close out capture evidence
Start each repeat after its own manifest entry, refuse packets from a restarted or stopped camera, keep a simulated repeat from reading as recorded evidence, and decide completeness from the writer report. Add the operator summary (four files plus an atomic pointer) and read-only video review of a hold window. Verified with the full Autocal suite (567 passed), the shared recorder/playback files (57 passed), the lint gate and the documentation gate; the two pre-existing panel_check layout failures also fail on a clean HEAD worktree.
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
"""Synthetic recording evidence for the acquisition, summary and review tests.
|
||||
|
||||
Two rules govern this module:
|
||||
|
||||
* a fixture writes the **shared recorder's real headers**, so a reader is tested
|
||||
against the files it will meet rather than against a convenient subset;
|
||||
* a video fixture is a **real decodable clip**. A file of fake MP4 bytes proves
|
||||
nothing about seeking, decoding or frame identity.
|
||||
|
||||
Nothing here touches the app's preference file, its ``outputs/`` directory or a
|
||||
real camera. Fixtures are built inside the test's ``tmp_path``; the run's
|
||||
``--basetemp`` points at the task scratch directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.record import FORCES_HEADER, FRAMES_HEADER
|
||||
|
||||
#: One working day in nanoseconds: holds inside a fixture are this far apart.
|
||||
HOLD_STRIDE_NS = 10_000_000_000
|
||||
#: A fixed, arbitrary session-relative origin for synthetic host timestamps.
|
||||
BASE_NS = 1_700_000_000_000_000_000
|
||||
|
||||
FRAMES_FIELDS = list(FRAMES_HEADER)
|
||||
FORCES_FIELDS = list(FORCES_HEADER)
|
||||
|
||||
|
||||
def write_csv(path: Path, fields: list[str], rows: list[dict]) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return path
|
||||
|
||||
|
||||
def read_csv(path: Path) -> list[dict]:
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def frame_row(
|
||||
frame_id: int,
|
||||
t_host_mono_ns: int,
|
||||
*,
|
||||
segment: int = 1,
|
||||
segment_frame: int | None = None,
|
||||
in_video: int = 1,
|
||||
dropped: int = 0,
|
||||
time_basis: str = "host_camera",
|
||||
t_rel_s: float = 0.0,
|
||||
) -> dict:
|
||||
return {
|
||||
"frame_id": frame_id,
|
||||
"source_frame_id": frame_id,
|
||||
"t_rel_s": repr(float(t_rel_s)),
|
||||
"t_host_mono_ns": int(t_host_mono_ns),
|
||||
"t_host_utc_ns": int(t_host_mono_ns),
|
||||
"acquired_at": "2026-10-17T00:00:00.000+00:00",
|
||||
"time_basis": time_basis,
|
||||
"media_time_ms": "",
|
||||
"segment": segment if in_video else 0,
|
||||
"segment_frame": (segment_frame if segment_frame is not None else frame_id)
|
||||
if in_video
|
||||
else 0,
|
||||
"in_video": in_video,
|
||||
"dropped": dropped,
|
||||
"capture_dropped": 0,
|
||||
}
|
||||
|
||||
|
||||
def force_row(
|
||||
frame_id: int,
|
||||
*,
|
||||
fn: float | None,
|
||||
fx: float | None = 0.0,
|
||||
fy: float | None = 0.0,
|
||||
valid: int = 1,
|
||||
session_id: int = 7,
|
||||
t_rel_s: float = 0.0,
|
||||
) -> dict:
|
||||
def cell(value):
|
||||
return "" if value is None else repr(float(value))
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"frame_id": frame_id,
|
||||
"t_rel_s": repr(float(t_rel_s)),
|
||||
"FN_raw": cell(fn),
|
||||
"FX_raw": cell(fx),
|
||||
"FY_raw": cell(fy),
|
||||
"FN_N": "",
|
||||
"FX_N": "",
|
||||
"FY_N": "",
|
||||
"valid": valid,
|
||||
"calibration_id": "",
|
||||
"FN_output_raw": "",
|
||||
"FX_output_raw": "",
|
||||
"FY_output_raw": "",
|
||||
"FN_output_N": "",
|
||||
"FX_output_N": "",
|
||||
"FY_output_N": "",
|
||||
"Fmag_raw": "",
|
||||
}
|
||||
|
||||
|
||||
def write_video(path: Path, *, frames: int = 6, fps: float = 10.0) -> Path:
|
||||
"""A real, short, decodable clip whose frame number is visible in the picture.
|
||||
|
||||
Frame ``i`` carries a white 8x8 marker at column ``4 + (i % 6) * 8``. A test
|
||||
can therefore ask the decoder which frame it got instead of trusting a seek.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
size = (64, 48) # width, height
|
||||
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), size)
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError("the MP4 writer is unavailable in this environment")
|
||||
try:
|
||||
for index in range(frames):
|
||||
image = np.zeros((size[1], size[0], 3), dtype=np.uint8)
|
||||
column = 4 + (index % 6) * 8
|
||||
image[4:12, column : column + 8] = 255
|
||||
writer.write(image)
|
||||
finally:
|
||||
writer.release()
|
||||
return path
|
||||
|
||||
|
||||
def decoded_frame_number(path: Path, *, segment_frame: int | None = None) -> int:
|
||||
"""Decode one frame of a :func:`write_video` clip and read its marker column."""
|
||||
import cv2
|
||||
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
try:
|
||||
if segment_frame is not None:
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
|
||||
ok, image = capture.read()
|
||||
if not ok:
|
||||
raise RuntimeError(f"could not decode {path.name}")
|
||||
column = int(image[4:12].sum(axis=(0, 2)).argmax())
|
||||
return (column - 4) // 8
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
|
||||
def write_capture(
|
||||
directory: Path,
|
||||
frames: list[dict],
|
||||
forces: list[dict],
|
||||
*,
|
||||
fields: list[str] | None = None,
|
||||
video_segments: int = 1,
|
||||
video_frames: int = 6,
|
||||
) -> Path:
|
||||
"""Write ``frames.csv``, ``forces.csv`` and one real MP4 per video segment."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
write_csv(directory / "frames.csv", fields or FRAMES_FIELDS, frames)
|
||||
write_csv(directory / "forces.csv", FORCES_FIELDS, forces)
|
||||
for segment in range(1, video_segments + 1):
|
||||
write_video(directory / f"raw_{segment:04d}.mp4", frames=video_frames)
|
||||
write_json_file(
|
||||
directory / "session.json",
|
||||
{
|
||||
"format_version": "1.4",
|
||||
"session_id": forces[0]["session_id"] if forces else 7,
|
||||
"files": {"frames": "frames.csv", "forces": "forces.csv", "video": "raw_%04d.mp4"},
|
||||
},
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def write_json_file(path: Path, payload: dict) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hold:
|
||||
"""One declared reference window as the campaign writes it."""
|
||||
|
||||
step_index: int
|
||||
branch: str
|
||||
target_load_n: float
|
||||
reference_n: float
|
||||
start_ns: int
|
||||
end_ns: int
|
||||
complete: int = 1
|
||||
|
||||
@property
|
||||
def stability_verified(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def reference_row(hold: Hold, *, site_id: str, repeat_index: int, repeat_count: int) -> dict:
|
||||
row = {name: "" for name in (
|
||||
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
|
||||
"t_end_host_mono_ns", "F_ref_normal_N", "site_id", "x", "y", "z",
|
||||
"coordinate_frame", "repeat_index", "repeat_count", "hold_complete",
|
||||
"reference_source", "fx_N", "fy_N", "fz_N", "force_to_tool_rotation",
|
||||
"nx", "ny", "nz", "pose_x_mm", "pose_y_mm", "pose_yaw_deg",
|
||||
"pose_pitch_deg", "pose_z_mm", "normal_aligned_to",
|
||||
)}
|
||||
row.update({
|
||||
"step_index": hold.step_index,
|
||||
"branch": hold.branch,
|
||||
"target_load_n": hold.target_load_n,
|
||||
"t_start_host_mono_ns": hold.start_ns,
|
||||
"t_end_host_mono_ns": hold.end_ns,
|
||||
"F_ref_normal_N": hold.reference_n,
|
||||
"site_id": site_id,
|
||||
"x": 0.0, "y": 0.0, "z": 0.0,
|
||||
"coordinate_frame": "sensor",
|
||||
"repeat_index": repeat_index,
|
||||
"repeat_count": repeat_count,
|
||||
"hold_complete": hold.complete,
|
||||
"reference_source": "weight",
|
||||
})
|
||||
return row
|
||||
|
||||
|
||||
REFERENCE_FIELDS = list(reference_row(
|
||||
Hold(0, "baseline", 0.0, 0.0, 0, 1), site_id="", repeat_index=1, repeat_count=1,
|
||||
).keys())
|
||||
|
||||
|
||||
def holds_for_levels(
|
||||
levels: list[float], *, first_start_ns: int = BASE_NS, hold_ns: int = 4_000_000_000,
|
||||
) -> list[Hold]:
|
||||
"""baseline, one load per level, then unload back to zero."""
|
||||
steps: list[tuple[str, float]] = [("baseline", 0.0)]
|
||||
steps += [("load", float(level)) for level in levels]
|
||||
steps += [("unload", value) for value in [*reversed(levels[:-1]), 0.0]]
|
||||
holds = []
|
||||
for index, (branch, target) in enumerate(steps):
|
||||
start = first_start_ns + index * HOLD_STRIDE_NS
|
||||
holds.append(Hold(index, branch, target, target, start, start + hold_ns))
|
||||
return holds
|
||||
|
||||
|
||||
def hold_samples(
|
||||
hold: Hold,
|
||||
*,
|
||||
count: int = 3,
|
||||
first_frame_id: int,
|
||||
raw_base: float,
|
||||
raw_per_n: float,
|
||||
raw_per_axis: dict[str, float] | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Evenly spaced samples inside a hold, raw linear in the declared force."""
|
||||
frames: list[dict] = []
|
||||
forces: list[dict] = []
|
||||
span = (hold.end_ns - hold.start_ns) // (count + 1)
|
||||
fn = raw_base + raw_per_n * hold.reference_n
|
||||
for offset in range(count):
|
||||
frame_id = first_frame_id + offset
|
||||
stamp = hold.start_ns + span * (offset + 1)
|
||||
frames.append(frame_row(frame_id, stamp, t_rel_s=(stamp - BASE_NS) / 1e9))
|
||||
values = {"fn": fn}
|
||||
if raw_per_axis:
|
||||
values.update({
|
||||
"fx": raw_per_axis["FX"] * hold.reference_n,
|
||||
"fy": raw_per_axis["FY"] * hold.reference_n,
|
||||
})
|
||||
forces.append(force_row(frame_id, **values))
|
||||
return frames, forces
|
||||
|
||||
|
||||
def _write_multipoint_trial(
|
||||
path: Path,
|
||||
*,
|
||||
session_id: str,
|
||||
site_id: str,
|
||||
repeat_index: int,
|
||||
repeat_count: int,
|
||||
holds: list[Hold],
|
||||
with_capture: bool,
|
||||
video: bool,
|
||||
raw_base: float,
|
||||
raw_per_n: float,
|
||||
samples_per_hold: int,
|
||||
frame_start: int,
|
||||
) -> list[dict]:
|
||||
write_csv(
|
||||
path / "reference.csv",
|
||||
REFERENCE_FIELDS,
|
||||
[reference_row(hold, site_id=site_id, repeat_index=repeat_index,
|
||||
repeat_count=repeat_count) for hold in holds],
|
||||
)
|
||||
write_csv(
|
||||
path / "events.csv",
|
||||
["t_host_mono_ns", "t_host_utc_ns", "kind", "detail"],
|
||||
[],
|
||||
)
|
||||
write_json_file(path / "session.json", {
|
||||
"trial_id": f"{site_id}-repeat-{repeat_index:03d}",
|
||||
"site_id": site_id,
|
||||
"status": "complete",
|
||||
"uv_px": {"u": 128, "v": 64},
|
||||
"images": {"captured": with_capture,
|
||||
"reason": "" if with_capture else "camera not attached yet"},
|
||||
})
|
||||
if not with_capture:
|
||||
return []
|
||||
frames: list[dict] = []
|
||||
forces: list[dict] = []
|
||||
for hold in holds:
|
||||
hold_frames, hold_forces = hold_samples(
|
||||
hold,
|
||||
count=samples_per_hold,
|
||||
first_frame_id=frame_start + len(frames),
|
||||
raw_base=raw_base,
|
||||
raw_per_n=raw_per_n,
|
||||
)
|
||||
frames.extend(hold_frames)
|
||||
forces.extend(hold_forces)
|
||||
write_capture(path / "capture", frames, forces, fields=None if video else FRAMES_FIELDS,
|
||||
video_segments=1)
|
||||
return []
|
||||
|
||||
|
||||
def multipoint_session(
|
||||
root: Path,
|
||||
*,
|
||||
sites: tuple[str, ...] = ("P1",),
|
||||
repeats: int = 1,
|
||||
levels: tuple[float, ...] = (5.0,),
|
||||
with_capture: bool = True,
|
||||
video: bool = True,
|
||||
raw_base: float = 100.0,
|
||||
raw_per_n: float = 40.0,
|
||||
samples_per_hold: int = 3,
|
||||
simulate: bool = False,
|
||||
trial_status: str = "complete",
|
||||
extra_holds: list[Hold] | None = None,
|
||||
) -> Path:
|
||||
"""A complete multi-point session directory through the review contract."""
|
||||
session_id = f"finger-SN1_{root.name}_test0001"
|
||||
trials: list[dict] = []
|
||||
completion: list[dict] = []
|
||||
site_records = []
|
||||
for site_id in sites:
|
||||
site_records.append({
|
||||
"site_id": site_id,
|
||||
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
"position_frame": "sensor",
|
||||
"uv_px": {"u": 128, "v": 64},
|
||||
"levels_n": list(levels),
|
||||
"hold_s": 4.0,
|
||||
"repeats": repeats,
|
||||
})
|
||||
complete = 0
|
||||
for repeat in range(1, repeats + 1):
|
||||
site_dir = root / "sites" / site_id / f"repeat-{repeat:03d}"
|
||||
holds = holds_for_levels(list(levels))
|
||||
if extra_holds:
|
||||
holds = [*holds, *extra_holds]
|
||||
directory = f"sites/{site_id}/repeat-{repeat:03d}"
|
||||
_write_multipoint_trial(
|
||||
site_dir,
|
||||
session_id=session_id,
|
||||
site_id=site_id,
|
||||
repeat_index=repeat,
|
||||
repeat_count=repeats,
|
||||
holds=holds,
|
||||
with_capture=with_capture,
|
||||
video=video,
|
||||
raw_base=raw_base,
|
||||
raw_per_n=raw_per_n,
|
||||
samples_per_hold=samples_per_hold,
|
||||
frame_start=1,
|
||||
)
|
||||
trials.append({
|
||||
"trial_id": f"{site_id}-repeat-{repeat:03d}",
|
||||
"site_id": site_id,
|
||||
"repeat_index": repeat,
|
||||
"directory": directory,
|
||||
"status": trial_status,
|
||||
"reason": "" if trial_status == "complete" else "stopped by the operator",
|
||||
})
|
||||
if trial_status == "complete":
|
||||
complete += 1
|
||||
completion.append({"site_id": site_id, "repeats_planned": repeats,
|
||||
"repeats_complete": complete,
|
||||
"complete": complete >= repeats})
|
||||
write_json_file(root / "session.json", {
|
||||
"format_version": "autocal-multipoint-1.0",
|
||||
"session_id": session_id,
|
||||
"sensor_model": "finger",
|
||||
"sensor_sn": "SN1",
|
||||
"coordinate_frame": "sensor",
|
||||
"max_force_n": 20.0,
|
||||
"min_force_n": 0.0,
|
||||
"simulate": simulate,
|
||||
"status": "complete" if trial_status == "complete" else "incomplete",
|
||||
"sites": site_records,
|
||||
"trials": trials,
|
||||
"completion": completion,
|
||||
"started_at": "2026-10-17T00:00:00+00:00",
|
||||
"sync": {"association": "A frame belongs to a hold when its t_host_mono_ns is in "
|
||||
"[t_start_host_mono_ns, t_end_host_mono_ns). Rows are not paired."},
|
||||
"platform_motion": False,
|
||||
"automatic_force_control": False,
|
||||
})
|
||||
return root
|
||||
|
||||
|
||||
def single_session(
|
||||
root: Path,
|
||||
*,
|
||||
sites: tuple[str, ...] = ("site-001",),
|
||||
trials_per_site: int = 1,
|
||||
levels: tuple[float, ...] = (5.0,),
|
||||
with_capture: bool = True,
|
||||
raw_base: float = 100.0,
|
||||
raw_per_n: float = 40.0,
|
||||
samples_per_hold: int = 3,
|
||||
trial_status: str = "complete",
|
||||
reference_alignment_possible: bool = True,
|
||||
) -> Path:
|
||||
"""A complete single-trial calibration session (``autocal-session-1.0``)."""
|
||||
session_id = f"finger-SN1_{root.name}_test0001"
|
||||
uv_definition = {
|
||||
"unit": "px", "u_positive": "right", "v_positive": "up",
|
||||
"origin_raster_xy_px": [0, 0], "raster_size_hw": [64, 64],
|
||||
"mapping_sha256": "test-mapping",
|
||||
}
|
||||
site_records = [
|
||||
{"site_id": site_id, "uv_xy_px": [10 + index, 20 + index],
|
||||
"position_sensor_mm": [1.0, 2.0, 3.0]}
|
||||
for index, site_id in enumerate(sites)
|
||||
]
|
||||
trials: list[dict] = []
|
||||
for site_index, site_id in enumerate(sites):
|
||||
for repeat in range(1, trials_per_site + 1):
|
||||
trial_id = f"trial-{len(trials) + 1:03d}"
|
||||
directory = f"trials/{trial_id}"
|
||||
path = root / directory
|
||||
holds = holds_for_levels(list(levels))
|
||||
write_csv(path / "reference.csv", REFERENCE_FIELDS, [
|
||||
reference_row(hold, site_id=site_id, repeat_index=repeat, repeat_count=1)
|
||||
for hold in holds
|
||||
])
|
||||
frames: list[dict] = []
|
||||
forces: list[dict] = []
|
||||
if with_capture:
|
||||
for hold in holds:
|
||||
hold_frames, hold_forces = hold_samples(
|
||||
hold, count=samples_per_hold,
|
||||
first_frame_id=1 + len(frames),
|
||||
raw_base=raw_base, raw_per_n=raw_per_n,
|
||||
)
|
||||
frames.extend(hold_frames)
|
||||
forces.extend(hold_forces)
|
||||
write_csv(path / "frames.csv", FRAMES_FIELDS, frames)
|
||||
write_csv(path / "forces.csv", FORCES_FIELDS, forces)
|
||||
write_json_file(path / "session.json", {
|
||||
"trial_id": trial_id,
|
||||
"site_id": site_id,
|
||||
"session_id": 7,
|
||||
"calibration_session_id": session_id,
|
||||
"trial_status": trial_status,
|
||||
"trial_reason": "" if trial_status == "complete" else "application closed",
|
||||
"reference_alignment_possible": reference_alignment_possible,
|
||||
"uv_definition": uv_definition,
|
||||
"steps": [{"step_index": hold.step_index, "branch": hold.branch,
|
||||
"target_load_n": hold.target_load_n} for hold in holds],
|
||||
})
|
||||
trials.append({
|
||||
"trial_id": trial_id, "site_id": site_id, "repeat_index": repeat,
|
||||
"directory": directory, "status": trial_status,
|
||||
"reason": "" if trial_status == "complete" else "application closed",
|
||||
})
|
||||
write_json_file(root / "session.json", {
|
||||
"format_version": "autocal-session-1.0",
|
||||
"session_id": session_id,
|
||||
"status": "complete" if trial_status == "complete" else "incomplete",
|
||||
"sites": site_records,
|
||||
"trials": trials,
|
||||
"uv_definition": uv_definition,
|
||||
"sensor_model": "finger",
|
||||
"sensor_sn": "SN1",
|
||||
"started_at": "2026-10-17T00:00:00+00:00",
|
||||
})
|
||||
return root
|
||||
|
||||
|
||||
class FakeWorker:
|
||||
"""A fingerprint worker stand-in whose session can be restarted mid-recording."""
|
||||
|
||||
def __init__(self, *, session_id: int = 1, frame_id: int = 1, time_basis: str = "host_camera",
|
||||
clock=None):
|
||||
self.session_id = int(session_id)
|
||||
self.is_running = True
|
||||
self.sinks: list = []
|
||||
self.time_basis = time_basis
|
||||
self._clock = clock
|
||||
self._frame_id = int(frame_id)
|
||||
self._started = self._now_ns()
|
||||
self.packet = self._packet()
|
||||
|
||||
def _now_ns(self) -> int:
|
||||
return time.perf_counter_ns() if self._clock is None else int(self._clock())
|
||||
|
||||
def _packet(self) -> SdkPacket:
|
||||
import numpy as np
|
||||
|
||||
now = self._now_ns()
|
||||
return SdkPacket(
|
||||
raw_frame=np.zeros((16, 16, 3), dtype=np.uint8),
|
||||
processed_frame=np.zeros((16, 16, 3), dtype=np.uint8),
|
||||
timestamp=time.time(),
|
||||
fps=30,
|
||||
process_ms=1,
|
||||
session_id=self.session_id,
|
||||
frame_id=self._frame_id,
|
||||
mono_t=(now - self._started) / 1e9,
|
||||
capture_mono_ns=now,
|
||||
capture_utc_ns=time.time_ns(),
|
||||
source_frame_id=self._frame_id,
|
||||
time_basis=self.time_basis,
|
||||
normal_force_raw=1.0,
|
||||
)
|
||||
|
||||
def advance(self) -> SdkPacket:
|
||||
self._frame_id += 1
|
||||
self.packet = self._packet()
|
||||
return self.packet
|
||||
|
||||
def latest(self):
|
||||
return self.packet
|
||||
|
||||
def add_sink(self, sink) -> None:
|
||||
if sink not in self.sinks:
|
||||
self.sinks.append(sink)
|
||||
|
||||
def remove_sink(self, sink) -> None:
|
||||
if sink in self.sinks:
|
||||
self.sinks.remove(sink)
|
||||
|
||||
def emit(self) -> None:
|
||||
for sink in list(self.sinks):
|
||||
sink(self.packet)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""A new acquisition session with fresh frame numbers, sinks still attached."""
|
||||
self.session_id += 1
|
||||
self._frame_id = 1
|
||||
self._started = self._now_ns()
|
||||
self.packet = self._packet()
|
||||
|
||||
def stop(self, *, wait: bool = True) -> None:
|
||||
self.is_running = False
|
||||
@@ -62,6 +62,9 @@ _SCOPED_ENUMS = {
|
||||
"TextFlag",
|
||||
"AspectRatioMode",
|
||||
"WindowType",
|
||||
# ``Qt.WidgetAttribute.WA_DeleteOnClose`` is the scoped spelling too; the
|
||||
# class simply was not needed before the video review dialog.
|
||||
"WidgetAttribute",
|
||||
),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""A force row belongs to a frame by identity, never by its line number."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from evidence_helpers import (
|
||||
BASE_NS,
|
||||
force_row,
|
||||
frame_row,
|
||||
write_capture,
|
||||
)
|
||||
from orisys_autocal.frames_index import (
|
||||
DUPLICATE_REASON,
|
||||
joined_samples,
|
||||
read_frame_index,
|
||||
window_samples,
|
||||
)
|
||||
|
||||
|
||||
def _capture(tmp_path, frames, forces, **kwargs):
|
||||
return write_capture(tmp_path / "capture", frames, forces, video_segments=0, **kwargs)
|
||||
|
||||
|
||||
def test_samples_are_paired_by_frame_id_not_by_row_order(tmp_path):
|
||||
frames = [
|
||||
frame_row(1, BASE_NS + 10),
|
||||
frame_row(2, BASE_NS + 20),
|
||||
frame_row(3, BASE_NS + 30),
|
||||
]
|
||||
# The force rows are reversed on disk, as an out-of-order write would leave them.
|
||||
forces = [force_row(3, fn=30.0), force_row(2, fn=20.0), force_row(1, fn=10.0)]
|
||||
_capture(tmp_path, frames, forces)
|
||||
|
||||
samples, report = joined_samples(tmp_path / "capture")
|
||||
|
||||
assert [sample.frame_id for sample in samples] == [1, 2, 3]
|
||||
assert [sample.t_host_mono_ns for sample in samples] == [
|
||||
BASE_NS + 10, BASE_NS + 20, BASE_NS + 30,
|
||||
]
|
||||
assert [sample.value("FN") for sample in samples] == [10.0, 20.0, 30.0]
|
||||
assert report.unjoined_force_rows == 0
|
||||
|
||||
window = window_samples(samples, BASE_NS + 15, BASE_NS + 30)
|
||||
assert [sample.frame_id for sample in window] == [2]
|
||||
|
||||
|
||||
def test_a_repeated_frame_id_excludes_the_samples_it_would_pair(tmp_path):
|
||||
"""A camera that restarted reuses frame numbers; neither copy is the measurement."""
|
||||
frames = [
|
||||
frame_row(1, BASE_NS + 10),
|
||||
frame_row(2, BASE_NS + 20),
|
||||
frame_row(1, BASE_NS + 1_000),
|
||||
frame_row(2, BASE_NS + 1_010),
|
||||
]
|
||||
forces = [
|
||||
force_row(1, fn=10.0),
|
||||
force_row(2, fn=20.0),
|
||||
force_row(1, fn=11.0),
|
||||
force_row(2, fn=21.0),
|
||||
]
|
||||
_capture(tmp_path, frames, forces)
|
||||
|
||||
samples, report = joined_samples(tmp_path / "capture")
|
||||
|
||||
assert samples == []
|
||||
assert report.duplicate_frame_ids == 2
|
||||
assert DUPLICATE_REASON in report.reasons
|
||||
|
||||
|
||||
def test_frames_outside_the_video_are_kept_only_when_asked_for(tmp_path):
|
||||
frames = [
|
||||
frame_row(1, BASE_NS + 10, in_video=1),
|
||||
frame_row(2, BASE_NS + 20, in_video=0),
|
||||
frame_row(3, BASE_NS + 30, in_video=1),
|
||||
]
|
||||
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
|
||||
_capture(tmp_path, frames, forces)
|
||||
|
||||
with_video, report = joined_samples(tmp_path / "capture", require_video=True)
|
||||
everything, _ = joined_samples(tmp_path / "capture")
|
||||
|
||||
assert [sample.frame_id for sample in with_video] == [1, 3]
|
||||
assert [sample.frame_id for sample in everything] == [1, 2, 3]
|
||||
assert report.frames_not_in_video == 1
|
||||
|
||||
|
||||
def test_a_dropped_frame_row_has_no_time_to_pair_with(tmp_path):
|
||||
frames = [
|
||||
frame_row(1, BASE_NS + 10),
|
||||
frame_row(2, 0, dropped=1),
|
||||
frame_row(3, BASE_NS + 30),
|
||||
]
|
||||
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
|
||||
_capture(tmp_path, frames, forces)
|
||||
|
||||
samples, report = joined_samples(tmp_path / "capture")
|
||||
|
||||
assert [sample.frame_id for sample in samples] == [1, 3]
|
||||
assert report.unjoined_force_rows == 1
|
||||
assert report.frames_dropped == 1
|
||||
|
||||
|
||||
def test_a_recording_without_an_in_video_column_still_joins(tmp_path):
|
||||
"""Older recordings have no membership column; unknown is not "outside"."""
|
||||
fields = [
|
||||
name for name in (
|
||||
"frame_id", "source_frame_id", "t_rel_s", "t_host_mono_ns", "t_host_utc_ns",
|
||||
"acquired_at", "time_basis", "media_time_ms", "segment", "segment_frame",
|
||||
"dropped", "capture_dropped",
|
||||
)
|
||||
]
|
||||
frames = [
|
||||
{"frame_id": 1, "t_host_mono_ns": BASE_NS + 10, "dropped": 0, "t_rel_s": "0.0"},
|
||||
{"frame_id": 2, "t_host_mono_ns": BASE_NS + 20, "dropped": 0, "t_rel_s": "0.0"},
|
||||
]
|
||||
_capture(tmp_path, frames, [force_row(1, fn=1.0), force_row(2, fn=2.0)], fields=fields)
|
||||
|
||||
samples, report = joined_samples(tmp_path / "capture", require_video=True)
|
||||
|
||||
assert [sample.frame_id for sample in samples] == [1, 2]
|
||||
assert report.frames_not_in_video == 0
|
||||
|
||||
|
||||
def test_a_missing_axis_is_empty_not_zero(tmp_path):
|
||||
frames = [frame_row(1, BASE_NS + 10), frame_row(2, BASE_NS + 20)]
|
||||
forces = [
|
||||
force_row(1, fn=10.0, fx=0.5, fy=-0.25),
|
||||
force_row(2, fn=None, fx=None, fy=None, valid=0),
|
||||
]
|
||||
_capture(tmp_path, frames, forces)
|
||||
|
||||
samples, _ = joined_samples(tmp_path / "capture")
|
||||
|
||||
assert samples[0].values == (10.0, 0.5, -0.25)
|
||||
assert samples[1].value("FN") is None
|
||||
assert samples[1].valid is False
|
||||
assert samples[1].usable() is False
|
||||
assert samples[0].usable() is True
|
||||
|
||||
|
||||
def test_the_frame_index_keeps_repeated_ids_and_unknown_video_membership(tmp_path):
|
||||
frames = [
|
||||
frame_row(1, BASE_NS + 10, in_video=1),
|
||||
frame_row(1, BASE_NS + 20, in_video=0),
|
||||
]
|
||||
_capture(tmp_path, frames, [force_row(1, fn=1.0)])
|
||||
|
||||
index = read_frame_index(tmp_path / "capture")
|
||||
|
||||
assert [row.frame_id for row in index] == [1, 1]
|
||||
assert [row.in_video for row in index] == [True, False]
|
||||
|
||||
|
||||
def test_a_missing_capture_directory_reads_as_empty(tmp_path):
|
||||
samples, report = joined_samples(tmp_path / "absent")
|
||||
|
||||
assert samples == []
|
||||
assert report.frames_total == 0 and report.force_rows == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame_id", [0, 120])
|
||||
def test_frame_ids_are_used_verbatim(tmp_path, frame_id):
|
||||
"""Zero and large ids are still identities; nothing is renumbered."""
|
||||
frames = [frame_row(frame_id, BASE_NS + 10)]
|
||||
_capture(tmp_path, frames, [force_row(frame_id, fn=2.0)])
|
||||
|
||||
samples, _ = joined_samples(tmp_path / "capture")
|
||||
|
||||
assert samples[0].frame_id == frame_id
|
||||
@@ -347,8 +347,13 @@ def test_an_outlier_in_the_stable_half_is_left_out_of_its_own_mean(tmp_path):
|
||||
assert float(row["FY_mean"]) == pytest.approx(-0.375)
|
||||
|
||||
|
||||
def test_a_reconnected_camera_keeps_each_hold_on_its_own_frames(tmp_path):
|
||||
"""A camera that starts again reuses frame numbers. Each hold keeps its own rows."""
|
||||
def test_reused_frame_numbers_are_left_out_of_the_means(tmp_path):
|
||||
"""A camera that restarts reuses frame numbers; neither copy is a measurement.
|
||||
|
||||
Pairing by row order used to keep both copies, which silently moved a sample
|
||||
into the wrong hold. A repeated identity now excludes the samples it would
|
||||
pair and the row says so through ``samples``/``unit`` being empty.
|
||||
"""
|
||||
from orisys_autocal.multipoint import stable_component_rows
|
||||
|
||||
repeat = tmp_path / "repeat-001"
|
||||
@@ -389,10 +394,49 @@ def test_a_reconnected_camera_keeps_each_hold_on_its_own_frames(tmp_path):
|
||||
|
||||
rows = stable_component_rows(repeat, site_id="P1", repeat_index=1)
|
||||
|
||||
assert float(rows[0]["FN_mean"]) == pytest.approx(20.0)
|
||||
assert rows[0]["branch"] == "load"
|
||||
assert float(rows[1]["FN_mean"]) == pytest.approx(5.0)
|
||||
assert rows[1]["branch"] == "unload"
|
||||
assert rows[0]["branch"] == "load" and rows[1]["branch"] == "unload"
|
||||
assert all(row["samples"] == 0 and row["unit"] == "" for row in rows)
|
||||
assert all(row["FN_mean"] == row["FX_mean"] == row["FY_mean"] == "" for row in rows)
|
||||
|
||||
|
||||
def test_a_missing_force_row_does_not_shift_the_other_holds(tmp_path):
|
||||
"""The defect the line-number join caused: one lost row moved every later value."""
|
||||
from orisys_autocal.multipoint import stable_component_rows
|
||||
|
||||
repeat = tmp_path / "repeat-001"
|
||||
(repeat / "capture").mkdir(parents=True)
|
||||
first, second = (1_000, 1_100), (5_000, 5_100)
|
||||
with (repeat / "reference.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=[
|
||||
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
|
||||
"t_end_host_mono_ns", "hold_complete", "reference_source",
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerow({"step_index": 1, "branch": "load", "target_load_n": 5,
|
||||
"t_start_host_mono_ns": first[0], "t_end_host_mono_ns": first[1],
|
||||
"hold_complete": 1, "reference_source": "weight"})
|
||||
writer.writerow({"step_index": 2, "branch": "load", "target_load_n": 5,
|
||||
"t_start_host_mono_ns": second[0], "t_end_host_mono_ns": second[1],
|
||||
"hold_complete": 1, "reference_source": "weight"})
|
||||
with (repeat / "capture" / "frames.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=["frame_id", "t_host_mono_ns", "dropped"])
|
||||
writer.writeheader()
|
||||
for frame_id, stamp in ((1, 1060), (2, 5070)):
|
||||
writer.writerow({"frame_id": frame_id, "t_host_mono_ns": stamp, "dropped": 0})
|
||||
# Frame 1's force row was lost; only the second hold has a sample.
|
||||
with (repeat / "capture" / "forces.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=[
|
||||
"frame_id", "valid", "FN_output_N", "FX_output_N", "FY_output_N",
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerow({"frame_id": 2, "valid": 1,
|
||||
"FN_output_N": 42.0, "FX_output_N": 0.0, "FY_output_N": 0.0})
|
||||
|
||||
rows = stable_component_rows(repeat, site_id="P1", repeat_index=1)
|
||||
|
||||
assert rows[0]["FN_mean"] == "" and rows[0]["samples"] == 0
|
||||
assert float(rows[1]["FN_mean"]) == pytest.approx(42.0)
|
||||
assert rows[1]["samples"] == 1
|
||||
|
||||
|
||||
def _hold_for(campaign: Campaign, force: float) -> None:
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Multi-point capture: repeat ownership, one source session, one verdict.
|
||||
|
||||
These tests use the shared recorder with a fake worker and a real camera-sized
|
||||
packet, so the video file, frame index and closing report are the production
|
||||
ones. No controller and no real camera are involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from evidence_helpers import FakeWorker
|
||||
from orisys_autocal.multipoint import Campaign
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""One clock for both the campaign and the fake worker's frame stamps."""
|
||||
|
||||
def __init__(self, now: int) -> None:
|
||||
self.now = int(now)
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += round(seconds * 1e9)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
from orisys_autocal import multipoint as module
|
||||
|
||||
value = _Clock(time.perf_counter_ns())
|
||||
monkeypatch.setattr(module, "time", SimpleNamespace(
|
||||
perf_counter_ns=lambda: value.now,
|
||||
perf_counter=lambda: value.now / 1e9,
|
||||
time_ns=time.time_ns,
|
||||
))
|
||||
return value
|
||||
|
||||
|
||||
def _campaign(*, repeats: int = 2) -> Campaign:
|
||||
campaign = Campaign()
|
||||
campaign.add_site("P1", 1, 2, 3, [5], 1, repeats, max_force_n=20)
|
||||
return campaign
|
||||
|
||||
|
||||
def _start(campaign: Campaign, root, *, simulate: bool = False) -> "object":
|
||||
return campaign.start(
|
||||
root, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="fixture", max_force_n=20, simulate=simulate,
|
||||
)
|
||||
|
||||
|
||||
def _drain(campaign: Campaign, timeout: float = 20.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while campaign.capture_state == "closing" and time.monotonic() < deadline:
|
||||
campaign.poll()
|
||||
time.sleep(0.005)
|
||||
assert campaign.capture_state != "closing", "the writer never closed"
|
||||
|
||||
|
||||
def _record_step(campaign: Campaign, worker: FakeWorker, target: float, *, clock=None) -> None:
|
||||
campaign.confirm_reference(target)
|
||||
# Three frames spread across the hold: the summary averages the latter half
|
||||
# and needs two usable samples there, which is the review's own rule.
|
||||
for _ in range(3):
|
||||
if clock is not None:
|
||||
clock.advance(0.25)
|
||||
worker.advance()
|
||||
worker.emit()
|
||||
campaign.complete_hold(
|
||||
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
|
||||
)
|
||||
|
||||
|
||||
def _record_repeat(campaign: Campaign, worker: FakeWorker, *, clock=None) -> None:
|
||||
for _branch, target in list(campaign._steps):
|
||||
_record_step(campaign, worker, target, clock=clock)
|
||||
_drain(campaign)
|
||||
|
||||
|
||||
def _session(root) -> dict:
|
||||
return json.loads((root / "session.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_each_repeat_owns_its_own_video_and_manifest(tmp_path, clock):
|
||||
campaign = _campaign(repeats=2)
|
||||
root = _start(campaign, tmp_path)
|
||||
worker = FakeWorker(clock=lambda: clock.now)
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
assert campaign.capture_ready
|
||||
|
||||
_record_repeat(campaign, worker, clock=clock)
|
||||
assert campaign.phase == "load"
|
||||
assert campaign._trial_entry["trial_id"] == "P1-repeat-002"
|
||||
_record_repeat(campaign, worker, clock=clock)
|
||||
assert campaign.phase == "done"
|
||||
_drain(campaign)
|
||||
|
||||
session = _session(root)
|
||||
assert session["status"] == "complete"
|
||||
trials = {trial["trial_id"]: trial for trial in session["trials"]}
|
||||
assert set(trials) == {"P1-repeat-001", "P1-repeat-002"}
|
||||
for trial in trials.values():
|
||||
assert trial["status"] == "complete", trial
|
||||
assert trial["capture_status"] == "complete"
|
||||
assert trial["images"]["captured"] is True
|
||||
capture = root / trial["directory"] / "capture"
|
||||
assert (capture / "raw_0001.mp4").is_file()
|
||||
manifest = json.loads((root / trial["directory"] / "session.json").read_text(encoding="utf-8"))
|
||||
assert manifest["status"] == "complete"
|
||||
assert manifest["images"]["captured"] is True
|
||||
assert manifest["capture_status"] == "complete"
|
||||
assert manifest["capture_report"]["stats"]["video_frames"] == manifest["capture_report"]["stats"]["frames"] > 0
|
||||
|
||||
# The evidence the real recorder wrote is what the summary reads.
|
||||
from orisys_autocal.summary import build_summary
|
||||
|
||||
summary = build_summary(root)
|
||||
assert summary["windows"]
|
||||
assert all(row["capture_status"] == "complete" for row in summary["windows"])
|
||||
assert all(row["eligible"] for row in summary["windows"])
|
||||
assert all(row["FN_mean"] is not None for row in summary["windows"])
|
||||
|
||||
|
||||
def test_a_restarted_source_aborts_the_repeat_instead_of_mixing_identities(tmp_path):
|
||||
campaign = _campaign(repeats=1)
|
||||
root = _start(campaign, tmp_path)
|
||||
worker = FakeWorker(session_id=4)
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
_record_step(campaign, worker, campaign._steps[0][1])
|
||||
|
||||
# The camera is restarted under the attached sink: new session, frame 1 again.
|
||||
worker.restart()
|
||||
worker.emit()
|
||||
|
||||
assert campaign.poll()["phase"] in {"closing", "aborted"}
|
||||
_drain(campaign)
|
||||
|
||||
session = _session(root)
|
||||
trial = session["trials"][0]
|
||||
assert trial["status"] == "incomplete"
|
||||
assert trial["capture_status"] == "incomplete"
|
||||
assert trial["capture_reason"] == "source_restarted"
|
||||
assert trial["capture_report"]["guard"]["refused"] >= 1
|
||||
assert session["status"] == "incomplete"
|
||||
assert campaign.phase == "aborted"
|
||||
|
||||
|
||||
def test_no_recorded_frames_keep_a_confirmed_repeat_incomplete(tmp_path):
|
||||
"""The operator confirmed every hold, but the evidence is empty."""
|
||||
campaign = _campaign(repeats=1)
|
||||
root = _start(campaign, tmp_path)
|
||||
worker = FakeWorker()
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
|
||||
# No packet is emitted, so the writer holds no frame at all.
|
||||
for _branch, target in list(campaign._steps):
|
||||
campaign.confirm_reference(target)
|
||||
campaign.complete_hold(
|
||||
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
|
||||
)
|
||||
_drain(campaign)
|
||||
|
||||
session = _session(root)
|
||||
trial = session["trials"][0]
|
||||
assert trial["status"] == "incomplete"
|
||||
assert trial["capture_reason"] == "zero_frames"
|
||||
assert trial["capture_report"]["stats"]["frames"] == 0
|
||||
assert session["status"] == "incomplete"
|
||||
|
||||
|
||||
def test_a_stopped_source_keeps_the_repeat_incomplete(tmp_path):
|
||||
"""Frames that stop describing the end of the repeat are not complete evidence."""
|
||||
campaign = _campaign(repeats=1)
|
||||
root = _start(campaign, tmp_path)
|
||||
worker = FakeWorker()
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
_record_step(campaign, worker, campaign._steps[0][1])
|
||||
|
||||
# The camera goes away; the operator keeps confirming holds by hand.
|
||||
worker.is_running = False
|
||||
campaign.poll()
|
||||
assert campaign.phase in {"closing", "aborted"}
|
||||
_drain(campaign)
|
||||
|
||||
session = _session(root)
|
||||
trial = session["trials"][0]
|
||||
assert trial["capture_status"] == "incomplete"
|
||||
assert trial["capture_reason"] == "source_stopped"
|
||||
assert session["status"] == "incomplete"
|
||||
|
||||
|
||||
def test_a_simulated_repeat_is_steps_complete_but_not_recorded_evidence(tmp_path):
|
||||
campaign = _campaign(repeats=1)
|
||||
root = campaign.start(
|
||||
tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="fixture", max_force_n=20, simulate=True,
|
||||
)
|
||||
while campaign.phase in {"load", "hold"}:
|
||||
if campaign.phase == "load":
|
||||
campaign.confirm_reference(campaign._steps[campaign._step_index][1])
|
||||
campaign.complete_hold(
|
||||
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
|
||||
)
|
||||
|
||||
session = _session(root)
|
||||
trial = session["trials"][0]
|
||||
assert campaign.phase == "done"
|
||||
assert trial["status"] == "complete" # the operator finished the steps
|
||||
assert trial["capture_status"] == "not_recorded" # but nothing was recorded
|
||||
assert trial["capture_reason"] == "not_recorded"
|
||||
assert session["status"] == "complete"
|
||||
|
||||
from orisys_autocal.summary import build_summary
|
||||
|
||||
summary = build_summary(root)
|
||||
assert all(row["capture_status"] == "not_recorded" for row in summary["windows"])
|
||||
assert all(row["eligible"] is False for row in summary["windows"])
|
||||
|
||||
|
||||
def test_a_new_session_cannot_start_while_the_last_repeat_closes(tmp_path):
|
||||
campaign = _campaign(repeats=1)
|
||||
_start(campaign, tmp_path)
|
||||
worker = FakeWorker()
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
for _branch, target in list(campaign._steps):
|
||||
_record_step(campaign, worker, target)
|
||||
assert campaign.capture_state == "closing"
|
||||
assert campaign.busy and not campaign.is_active
|
||||
|
||||
with pytest.raises(RuntimeError, match="already running"):
|
||||
_start(campaign, tmp_path)
|
||||
_drain(campaign)
|
||||
# After the writer closed, a new session is allowed again.
|
||||
second = _start(campaign, tmp_path)
|
||||
assert second.is_dir()
|
||||
|
||||
|
||||
def test_attaching_a_source_without_a_frame_raises_and_leaves_no_capture(tmp_path):
|
||||
campaign = _campaign(repeats=1)
|
||||
_start(campaign, tmp_path)
|
||||
worker = FakeWorker()
|
||||
worker.packet = None
|
||||
|
||||
with pytest.raises(RuntimeError, match="frame"):
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
assert campaign.capture_state == "idle"
|
||||
assert campaign._recorder is None
|
||||
assert not (campaign.directory / "sites" / "P1" / "repeat-001" / "capture").exists()
|
||||
|
||||
|
||||
def test_a_new_session_does_not_inherit_the_previous_source_or_rotation(tmp_path):
|
||||
"""A live worker and a six-axis rotation from the last session must not leak."""
|
||||
campaign = _campaign(repeats=1)
|
||||
worker = FakeWorker()
|
||||
campaign.start(
|
||||
tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="fixture", max_force_n=20, simulate=False,
|
||||
)
|
||||
campaign.set_force_rotation([1, 0, 0, 0, 1, 0, 0, 0, 1])
|
||||
campaign.attach_capture(worker, fps=10.0)
|
||||
campaign.stop("operator stopped")
|
||||
_drain(campaign)
|
||||
|
||||
second = campaign.start(
|
||||
tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="fixture", max_force_n=20, simulate=True,
|
||||
)
|
||||
assert campaign._pending_worker is None
|
||||
assert campaign._rotation is None
|
||||
assert campaign.capture_state == "idle"
|
||||
repeat = second / "sites" / "P1" / "repeat-001"
|
||||
assert not (repeat / "capture").exists()
|
||||
manifest = json.loads((repeat / "session.json").read_text(encoding="utf-8"))
|
||||
assert manifest["images"]["captured"] is False
|
||||
assert "force_to_tool_rotation" not in _session(second)
|
||||
|
||||
|
||||
def test_a_repeat_directory_that_already_exists_is_never_reused(tmp_path):
|
||||
campaign = _campaign(repeats=2)
|
||||
root = _start(campaign, tmp_path)
|
||||
# A leftover directory from an interrupted attempt must not be written into.
|
||||
leftover = root / "sites" / "P1" / "repeat-002"
|
||||
leftover.mkdir(parents=True)
|
||||
with pytest.raises(RuntimeError, match="already exists"):
|
||||
campaign._advance_after_repeat()
|
||||
|
||||
|
||||
def test_the_confirm_button_waits_until_the_capture_is_recording(make_window, tmp_path):
|
||||
"""A hold confirmed before the writer exists would be evidence with no video."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = make_window()
|
||||
panel = window.calibration_panel
|
||||
multipoint = panel.multipoint
|
||||
worker = FakeWorker()
|
||||
panel.source_panel.worker = worker
|
||||
panel.source_panel.mode = SimpleNamespace(fps=10)
|
||||
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
|
||||
panel.output_path.setText(str(tmp_path))
|
||||
panel.sensor_sn.setText("SN1")
|
||||
|
||||
# A started campaign whose capture was never attached: not ready.
|
||||
multipoint.campaign.start(
|
||||
tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="sensor", max_force_n=20, simulate=False,
|
||||
)
|
||||
panel._sync()
|
||||
assert multipoint.campaign.phase == "load"
|
||||
assert not panel.confirm_button.isEnabled()
|
||||
assert t("multipoint.capture_wait") in panel.target_label.text()
|
||||
panel.reference.setText("0")
|
||||
panel._confirm()
|
||||
assert multipoint.campaign.phase == "load"
|
||||
|
||||
multipoint.campaign.attach_capture(worker, fps=10.0)
|
||||
panel._sync()
|
||||
assert panel.confirm_button.isEnabled()
|
||||
assert multipoint.campaign.capture_ready
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_restarted_source_is_reported_on_the_panel(make_window, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = make_window()
|
||||
panel = window.calibration_panel
|
||||
multipoint = panel.multipoint
|
||||
worker = FakeWorker()
|
||||
panel.source_panel.worker = worker
|
||||
panel.source_panel.mode = SimpleNamespace(fps=10)
|
||||
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
|
||||
multipoint.campaign.start(
|
||||
tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="sensor", max_force_n=20, simulate=False,
|
||||
)
|
||||
multipoint.campaign.attach_capture(worker, fps=10.0)
|
||||
multipoint.campaign.poll()
|
||||
|
||||
worker.restart()
|
||||
worker.emit()
|
||||
multipoint._poll()
|
||||
_drain(multipoint.campaign)
|
||||
multipoint._refresh()
|
||||
|
||||
assert multipoint.campaign.phase == "aborted"
|
||||
assert t("multipoint.capture_incomplete") in multipoint.capture_status.text()
|
||||
assert t("multipoint.capture_reason_restarted") in multipoint.capture_status.text()
|
||||
manifest = json.loads(
|
||||
(multipoint.campaign.directory / "sites" / "P1" / "repeat-001" / "session.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
assert manifest["capture_status"] == "incomplete"
|
||||
assert manifest["capture_reason"] == "source_restarted"
|
||||
window.close()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""One window table over both recording formats, with nothing invented."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from evidence_helpers import (
|
||||
BASE_NS,
|
||||
FORCES_FIELDS,
|
||||
FRAMES_FIELDS,
|
||||
Hold,
|
||||
HOLD_STRIDE_NS,
|
||||
multipoint_session,
|
||||
read_csv,
|
||||
single_session,
|
||||
write_csv,
|
||||
)
|
||||
from orisys_autocal.summary import (
|
||||
HOLD_WINDOW_METHOD,
|
||||
LEGACY_WINDOW_METHOD,
|
||||
REASON_INSUFFICIENT_SAMPLES,
|
||||
REASON_LEGACY,
|
||||
REASON_NO_FRAME_SOURCE,
|
||||
REASON_REMEASURED,
|
||||
SCHEMA_VERSION,
|
||||
build_summary,
|
||||
legacy_window_rows,
|
||||
)
|
||||
|
||||
|
||||
def _levels() -> tuple[float, ...]:
|
||||
return (5.0, 10.0)
|
||||
|
||||
|
||||
def _means(summary: dict) -> list[float | None]:
|
||||
return [row["FN_mean"] for row in summary["windows"]]
|
||||
|
||||
|
||||
def test_both_formats_produce_the_same_identity_and_means(tmp_path):
|
||||
multipoint = build_summary(multipoint_session(
|
||||
tmp_path / "multi", sites=("P1",), repeats=1, levels=_levels()))
|
||||
single = build_summary(single_session(
|
||||
tmp_path / "single", sites=("site-001",), trials_per_site=1, levels=_levels()))
|
||||
|
||||
for summary in (multipoint, single):
|
||||
assert summary["schema_version"] == SCHEMA_VERSION
|
||||
assert summary["formal_conclusion"] is False
|
||||
assert summary["units"]["raw"] == "raw"
|
||||
assert summary["methods"]["fit"] == "raw_on_reference_ols_v1"
|
||||
assert len(summary["windows"]) == 5
|
||||
assert [row["branch"] for row in summary["windows"]] == [
|
||||
"baseline", "load", "load", "unload", "unload"]
|
||||
assert [row["hold_id"] for row in summary["windows"]] == [1, 2, 3, 4, 5]
|
||||
assert all(row["session_id"] for row in summary["windows"])
|
||||
assert all(row["trial_id"] for row in summary["windows"])
|
||||
assert all(row["source_file"].endswith("reference.csv") for row in summary["windows"])
|
||||
assert all(row["unit"] == "raw" for row in summary["windows"])
|
||||
assert all(row["window_method"] == HOLD_WINDOW_METHOD for row in summary["windows"])
|
||||
assert all(row["stability_verified"] is False for row in summary["windows"])
|
||||
|
||||
assert _means(multipoint) == pytest.approx([100.0, 300.0, 500.0, 300.0, 100.0])
|
||||
assert _means(single) == pytest.approx(_means(multipoint))
|
||||
assert [row["samples_valid"] for row in multipoint["windows"]] == [2, 2, 2, 2, 2]
|
||||
assert all(row["capture_status"] == "unknown" for row in multipoint["windows"])
|
||||
|
||||
|
||||
def test_a_six_axis_site_keeps_raw_components_apart(tmp_path):
|
||||
summary = build_summary(multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
|
||||
raw_base=100.0, raw_per_n=40.0))
|
||||
|
||||
load = summary["windows"][2]
|
||||
assert load["FN_mean"] == pytest.approx(500.0)
|
||||
assert load["FX_mean"] == pytest.approx(0.0)
|
||||
assert load["FY_mean"] == pytest.approx(0.0)
|
||||
assert load["FN_std"] == pytest.approx(0.0)
|
||||
# The newton columns never appear in this table.
|
||||
assert "FN_mean" in load and "FN_N" not in load
|
||||
sensitivity = {row["component"]: row for row in summary["sensitivity"]}
|
||||
assert sensitivity["FN"]["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert sensitivity["FN"]["status"] == "available"
|
||||
|
||||
|
||||
def test_a_remeasured_hold_is_listed_with_its_reason(tmp_path):
|
||||
replaced = Hold(
|
||||
step_index=1, branch="load", target_load_n=5.0, reference_n=5.0,
|
||||
start_ns=BASE_NS + 200 * HOLD_STRIDE_NS,
|
||||
end_ns=BASE_NS + 200 * HOLD_STRIDE_NS + 4_000_000_000,
|
||||
complete=0,
|
||||
)
|
||||
summary = build_summary(multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
|
||||
extra_holds=[replaced]))
|
||||
|
||||
assert len(summary["windows"]) == 6
|
||||
replaced_rows = [row for row in summary["windows"] if row["excluded_reason"] == REASON_REMEASURED]
|
||||
assert len(replaced_rows) == 1
|
||||
assert replaced_rows[0]["hold_id"] == 6
|
||||
assert replaced_rows[0]["eligible"] is False
|
||||
assert replaced_rows[0]["FN_mean"] is None
|
||||
assert replaced_rows[0]["samples_total"] == 0
|
||||
|
||||
|
||||
def test_a_missing_frame_source_is_reported_not_filled_in(tmp_path):
|
||||
summary = build_summary(multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
|
||||
with_capture=False))
|
||||
|
||||
assert all(row["eligible"] is False for row in summary["windows"])
|
||||
assert all(row["excluded_reason"] == REASON_NO_FRAME_SOURCE for row in summary["windows"])
|
||||
assert all(row["FN_mean"] is None for row in summary["windows"])
|
||||
assert all(row["capture_status"] == "missing" for row in summary["windows"])
|
||||
assert REASON_NO_FRAME_SOURCE in summary["missing_reasons"]
|
||||
assert summary["sensitivity"] == []
|
||||
|
||||
|
||||
def test_a_confirmed_window_uses_the_reviewed_interval(tmp_path):
|
||||
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
|
||||
from orisys_autocal.session_review import review_session
|
||||
|
||||
first = review_session(root)
|
||||
window = first["sites"][0]["windows"][2]
|
||||
selections = {f"{window['trial_id']}:{window['step_index']}": {
|
||||
"t_start_host_mono_ns": window["hold_start_host_mono_ns"] + 1,
|
||||
"t_end_host_mono_ns": window["hold_end_host_mono_ns"],
|
||||
"stability_verified": True,
|
||||
}}
|
||||
|
||||
summary = build_summary(root, selections=selections)
|
||||
|
||||
confirmed = [row for row in summary["windows"] if row["stability_verified"]]
|
||||
assert len(confirmed) == 1
|
||||
assert confirmed[0]["window_method"] == "review_selection_v1"
|
||||
assert confirmed[0]["review_status"] == "confirmed"
|
||||
assert confirmed[0]["t_start_host_mono_ns"] == window["hold_start_host_mono_ns"] + 1
|
||||
assert confirmed[0]["samples_valid"] == 3
|
||||
assert summary["selections_sha256"]
|
||||
assert {row["review_status"] for row in summary["windows"] if not row["stability_verified"]} == {"candidate"}
|
||||
|
||||
|
||||
def test_out_of_order_force_rows_do_not_change_the_means(tmp_path):
|
||||
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
|
||||
before = build_summary(root)
|
||||
capture = root / "sites" / "P1" / "repeat-001" / "capture"
|
||||
rows = read_csv(capture / "forces.csv")
|
||||
write_csv(capture / "forces.csv", FORCES_FIELDS, list(reversed(rows)))
|
||||
|
||||
after = build_summary(root)
|
||||
|
||||
assert _means(after) == pytest.approx(_means(before))
|
||||
|
||||
|
||||
def test_a_repeated_frame_identity_excludes_the_window_and_is_reported(tmp_path):
|
||||
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
|
||||
capture = root / "sites" / "P1" / "repeat-001" / "capture"
|
||||
frames = read_csv(capture / "frames.csv")
|
||||
forces = read_csv(capture / "forces.csv")
|
||||
last_frames = frames[-3:]
|
||||
last_forces = forces[-3:]
|
||||
for frame, force in zip(last_frames, last_forces):
|
||||
copy = dict(frame)
|
||||
copy["t_host_mono_ns"] = int(frame["t_host_mono_ns"]) + 1
|
||||
frames.append(copy)
|
||||
copied = dict(force)
|
||||
copied["FN_raw"] = repr(float(force["FN_raw"]) + 1.0)
|
||||
forces.append(copied)
|
||||
write_csv(capture / "frames.csv", FRAMES_FIELDS, frames)
|
||||
write_csv(capture / "forces.csv", FORCES_FIELDS, forces)
|
||||
|
||||
summary = build_summary(root)
|
||||
|
||||
assert any(issue["reason"] == "duplicate_frame_identity" for issue in summary["issues"])
|
||||
last = summary["windows"][-1]
|
||||
assert last["samples_total"] == 0
|
||||
assert last["excluded_reason"] == REASON_INSUFFICIENT_SAMPLES
|
||||
assert last["eligible"] is False
|
||||
# The earlier windows still have their own, unambiguous samples.
|
||||
assert all(row["samples_valid"] == 2 for row in summary["windows"][:-1])
|
||||
|
||||
|
||||
def test_two_sessions_are_never_merged(tmp_path):
|
||||
first = build_summary(multipoint_session(
|
||||
tmp_path / "a", sites=("P1",), repeats=1, levels=_levels()))
|
||||
second = build_summary(multipoint_session(
|
||||
tmp_path / "b", sites=("P2",), repeats=2, levels=(5.0,)))
|
||||
|
||||
assert {row["session_id"] for row in first["windows"]} == {first["session_id"]}
|
||||
assert {row["session_id"] for row in second["windows"]} == {second["session_id"]}
|
||||
assert first["session_id"] != second["session_id"]
|
||||
assert len(second["windows"]) == 6 # baseline, load, unload per repeat
|
||||
|
||||
|
||||
def test_an_old_stable_means_table_is_kept_but_never_fitted(tmp_path):
|
||||
path = tmp_path / "stable_means.csv"
|
||||
write_csv(path, [
|
||||
"site_id", "U", "V", "负载类型", "repeat_index", "step_index", "branch",
|
||||
"target_load_n", "samples", "FN_mean", "FX_mean", "FY_mean", "unit",
|
||||
], [{
|
||||
"site_id": "20261016point0", "U": "-145", "V": "604", "负载类型": "砝码",
|
||||
"repeat_index": 1, "step_index": 1, "branch": "load", "target_load_n": 1.0,
|
||||
"samples": 181, "FN_mean": 10013.7, "FX_mean": -6484.37, "FY_mean": 707.088,
|
||||
"unit": "raw",
|
||||
}])
|
||||
|
||||
rows = legacy_window_rows(path, summary_id="legacy-1")
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["window_method"] == LEGACY_WINDOW_METHOD
|
||||
assert row["eligible"] is False
|
||||
assert row["excluded_reason"] == REASON_LEGACY
|
||||
assert row["FN_mean"] == pytest.approx(10013.7)
|
||||
assert row["t_start_host_mono_ns"] is None and row["FN_std"] is None
|
||||
assert row["unit"] == "raw"
|
||||
|
||||
|
||||
def test_the_table_states_what_it_did_not_conclude(tmp_path):
|
||||
summary = build_summary(multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels()))
|
||||
|
||||
assert summary["formal_conclusion"] is False
|
||||
assert summary["methods"]["window_unverified"] == HOLD_WINDOW_METHOD
|
||||
assert summary["input_sha256"]
|
||||
assert summary["software"]["numpy"]
|
||||
assert summary["generated_at"].startswith("20")
|
||||
assert summary["summary_id"]
|
||||
assert all(row["summary_id"] == summary["summary_id"] for row in summary["windows"])
|
||||
assert summary["sensitivity_summary"]
|
||||
assert summary["sensitivity_summary"][0]["trials"] == 1
|
||||
assert summary["sensitivity_summary"][0]["slope_std_raw_per_N"] is None
|
||||
|
||||
|
||||
def test_a_sensitivity_row_exists_per_trial_and_review_set(tmp_path):
|
||||
summary = build_summary(multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=2, levels=_levels()))
|
||||
|
||||
fn_rows = [row for row in summary["sensitivity"] if row["component"] == "FN"]
|
||||
assert len(fn_rows) == 2
|
||||
assert {row["review_status"] for row in fn_rows} == {"candidate"}
|
||||
assert [row["window_count"] for row in fn_rows] == [3, 3]
|
||||
assert all(row["status"] == "available" for row in fn_rows)
|
||||
assert all(row["slope_raw_per_N"] == pytest.approx(40.0) for row in fn_rows)
|
||||
assert all(row["endpoint_raw_per_N"] == pytest.approx(40.0) for row in fn_rows)
|
||||
assert all(row["initial_step_raw"] == pytest.approx(200.0) for row in fn_rows)
|
||||
|
||||
|
||||
def test_an_axis_without_samples_is_unavailable_without_blocking_the_others(tmp_path):
|
||||
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
|
||||
capture = root / "sites" / "P1" / "repeat-001" / "capture"
|
||||
forces = read_csv(capture / "forces.csv")
|
||||
for row in forces:
|
||||
row["FX_raw"] = ""
|
||||
write_csv(capture / "forces.csv", FORCES_FIELDS, forces)
|
||||
|
||||
summary = build_summary(root)
|
||||
|
||||
by_axis = {row["component"]: row for row in summary["sensitivity"]}
|
||||
assert by_axis["FN"]["status"] == "available"
|
||||
assert by_axis["FX"]["status"] == "no_data"
|
||||
# A constant component has a unitless slope of zero and no R2; that is a
|
||||
# flat signal, not a gain.
|
||||
assert by_axis["FY"]["status"] == "flat_signal"
|
||||
assert by_axis["FY"]["R2"] is None
|
||||
assert by_axis["FY"]["slope_raw_per_N"] == pytest.approx(0.0)
|
||||
assert summary["windows"][0]["axis_samples"]["FX"] == 0
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Publishing a summary: one set of files, one pointer, never a partial result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from evidence_helpers import multipoint_session
|
||||
from orisys_autocal.summary import build_summary
|
||||
from orisys_autocal.summary_export import (
|
||||
POINTER_FORMAT,
|
||||
POINTER_NAME,
|
||||
SummaryExportError,
|
||||
load_summary,
|
||||
publish_summary,
|
||||
render_html,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(tmp_path):
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1", "P2"), repeats=2, levels=(5.0, 10.0))
|
||||
return root
|
||||
|
||||
|
||||
def _pointer(root):
|
||||
return json.loads((root / POINTER_NAME).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _read_csv(path):
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def test_publishing_writes_all_four_files_and_moves_the_pointer(session):
|
||||
summary = build_summary(session)
|
||||
|
||||
published = publish_summary(session, summary)
|
||||
|
||||
assert published.is_dir()
|
||||
assert published.name == f"summary_{summary['summary_id']}"
|
||||
for name in ("summary.json", "summary.html", "baseline_load_data.csv",
|
||||
"sensitivity_values.csv"):
|
||||
assert (published / name).is_file(), name
|
||||
pointer = _pointer(session)
|
||||
assert pointer["format_version"] == POINTER_FORMAT
|
||||
assert pointer["summary_id"] == summary["summary_id"]
|
||||
assert pointer["path"] == f"derived/{published.name}"
|
||||
assert set(pointer["files"]) == {
|
||||
"summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"}
|
||||
loaded, pointer_again = load_summary(session)
|
||||
assert loaded["summary_id"] == summary["summary_id"]
|
||||
assert pointer_again["summary_id"] == summary["summary_id"]
|
||||
# The export is a snapshot of the same object, not a second computation.
|
||||
stored = json.loads((published / "summary.json").read_text(encoding="utf-8"))
|
||||
assert stored["windows"] == summary["windows"]
|
||||
assert stored["sensitivity"] == summary["sensitivity"]
|
||||
|
||||
|
||||
def test_the_html_is_self_contained_and_escapes_operator_text(session):
|
||||
summary = build_summary(session)
|
||||
summary = dict(summary)
|
||||
summary["sensor_sn"] = "<script>alert('x')</script>"
|
||||
|
||||
document = render_html(summary)
|
||||
|
||||
assert "<script>" not in document
|
||||
assert "<script>" in document
|
||||
assert "http://" not in document and "https://" not in document
|
||||
assert "not a pass/fail certificate" in document
|
||||
|
||||
|
||||
def test_a_write_failure_leaves_the_previous_pointer_in_place(session, monkeypatch):
|
||||
import orisys_autocal.summary_export as module
|
||||
|
||||
first = build_summary(session)
|
||||
publish_summary(session, first)
|
||||
before = (session / POINTER_NAME).read_text(encoding="utf-8")
|
||||
|
||||
second = build_summary(session)
|
||||
second["generated_at"] = "2999-01-01T00:00:00+00:00"
|
||||
|
||||
def explode(*args, **kwargs):
|
||||
raise OSError("no space left on device")
|
||||
|
||||
monkeypatch.setattr(module, "_write_csv", explode)
|
||||
with pytest.raises(SummaryExportError, match="could not publish"):
|
||||
publish_summary(session, second)
|
||||
|
||||
assert (session / POINTER_NAME).read_text(encoding="utf-8") == before
|
||||
assert not (session / "derived" / f"summary_{second['summary_id']}").exists()
|
||||
leftovers = [path for path in (session / "derived").iterdir() if path.name.startswith(".tmp-")]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_the_pointer_never_moves_back_to_an_older_summary(session):
|
||||
first = build_summary(session)
|
||||
first["generated_at"] = "2026-10-18T00:00:00+00:00"
|
||||
publish_summary(session, first)
|
||||
older = build_summary(session)
|
||||
older["generated_at"] = "2026-10-17T00:00:00+00:00"
|
||||
|
||||
with pytest.raises(SummaryExportError, match="back"):
|
||||
publish_summary(session, older)
|
||||
|
||||
assert _pointer(session)["summary_id"] == first["summary_id"]
|
||||
|
||||
|
||||
def test_changed_inputs_refuse_to_publish(session):
|
||||
summary = build_summary(session)
|
||||
forces = session / "sites" / "P1" / "repeat-001" / "capture" / "forces.csv"
|
||||
text = forces.read_text(encoding="utf-8-sig")
|
||||
forces.write_text(text + "\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SummaryExportError, match="changed"):
|
||||
publish_summary(session, summary)
|
||||
assert not (session / POINTER_NAME).exists()
|
||||
|
||||
|
||||
def test_a_cancelled_export_writes_nothing(session):
|
||||
summary = build_summary(session)
|
||||
with pytest.raises(SummaryExportError, match="cancelled"):
|
||||
publish_summary(session, summary, cancelled=lambda: True)
|
||||
assert not (session / POINTER_NAME).exists()
|
||||
assert not (session / "derived").exists()
|
||||
|
||||
|
||||
def test_the_same_summary_is_not_published_twice(session):
|
||||
summary = build_summary(session)
|
||||
publish_summary(session, summary)
|
||||
with pytest.raises(SummaryExportError, match="already published"):
|
||||
publish_summary(session, summary)
|
||||
|
||||
|
||||
def test_load_refuses_a_tampered_export(session):
|
||||
summary = build_summary(session)
|
||||
published = publish_summary(session, summary)
|
||||
(published / "sensitivity_values.csv").write_text("changed", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SummaryExportError, match="modified"):
|
||||
load_summary(session)
|
||||
|
||||
|
||||
def test_the_csv_keeps_full_precision_and_utf8_text(session):
|
||||
summary = build_summary(session)
|
||||
summary["windows"][1]["FN_mean"] = 40.1234567890123
|
||||
summary["windows"][1]["site_id"] = "测点一"
|
||||
|
||||
published = publish_summary(session, summary)
|
||||
|
||||
rows = _read_csv(published / "baseline_load_data.csv")
|
||||
assert rows[1]["FN_mean"] == repr(40.1234567890123)
|
||||
assert float(rows[1]["FN_mean"]) == pytest.approx(40.1234567890123)
|
||||
assert rows[1]["site_id"] == "测点一"
|
||||
assert rows[1]["unit"] == "raw"
|
||||
assert rows[1]["window_method"] == "hold_second_half_v1"
|
||||
assert rows[1]["eligible"] == "1"
|
||||
assert rows[1]["samples_FN"] == "2"
|
||||
|
||||
|
||||
def test_the_sensitivity_csv_has_one_row_per_point_trial_axis(session):
|
||||
summary = build_summary(session)
|
||||
published = publish_summary(session, summary)
|
||||
|
||||
rows = _read_csv(published / "sensitivity_values.csv")
|
||||
|
||||
assert len(rows) == len(summary["sensitivity"]) == 2 * 2 * 3
|
||||
assert {row["component"] for row in rows} == {"FN", "FX", "FY"}
|
||||
assert {row["method"] for row in rows} == {"raw_on_reference_ols_v1"}
|
||||
assert {row["review_status"] for row in rows} == {"candidate"}
|
||||
fn = [row for row in rows if row["component"] == "FN"]
|
||||
assert all(float(row["slope_raw_per_N"]) == pytest.approx(40.0) for row in fn)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""The summary fit is a forward regression of raw on the declared reference.
|
||||
|
||||
Hand-computed lines, one nonlinear set, and the proof that its slope is not the
|
||||
reciprocal of the review's inverse fit (``N = gain * raw + offset``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.summary import fit_axis
|
||||
|
||||
|
||||
def _window(load: float, fn: float | None, *, branch: str = "load", verified: bool = False,
|
||||
eligible: bool = True, site: str = "P1", trial: str = "t1") -> dict:
|
||||
return {
|
||||
"session_id": "session-1", "site_id": site, "trial_id": trial,
|
||||
"repeat_index": 1, "step_index": 0, "branch": branch,
|
||||
"reference_load_n": load, "eligible": eligible,
|
||||
"stability_verified": verified,
|
||||
"FN_mean": fn, "FX_mean": 0.0, "FY_mean": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_a_straight_line_is_recovered_exactly():
|
||||
fit = fit_axis([(0.0, 100.0), (2.0, 180.0), (4.0, 260.0)])
|
||||
|
||||
assert fit["status"] == "available"
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert fit["intercept_raw"] == pytest.approx(100.0)
|
||||
assert fit["R2"] == pytest.approx(1.0)
|
||||
assert fit["rmse_raw"] == pytest.approx(0.0)
|
||||
assert fit["reference_min_N"] == 0.0 and fit["reference_max_N"] == 4.0
|
||||
assert fit["window_count"] == 3
|
||||
|
||||
|
||||
def test_a_nonlinear_set_matches_the_hand_computation():
|
||||
# x mean 1, y mean 166.667; slope 80; intercept 86.667; SSE 1066.667;
|
||||
# SST 13866.667 -> R2 1 - 1066.667 / 13866.667 = 0.923077.
|
||||
fit = fit_axis([(0.0, 100.0), (1.0, 140.0), (2.0, 260.0)])
|
||||
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(80.0)
|
||||
assert fit["intercept_raw"] == pytest.approx(86.6666666667, rel=1e-9)
|
||||
assert fit["R2"] == pytest.approx(0.9230769231, rel=1e-9)
|
||||
assert fit["rmse_raw"] == pytest.approx(math.sqrt(1066.6666667 / 3), rel=1e-9)
|
||||
|
||||
|
||||
def test_a_constant_signal_has_no_coefficient_of_determination():
|
||||
fit = fit_axis([(0.0, 5.0), (1.0, 5.0), (2.0, 5.0)])
|
||||
|
||||
assert fit["status"] == "flat_signal"
|
||||
assert fit["R2"] is None
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(0.0)
|
||||
assert fit["intercept_raw"] == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_one_reference_level_cannot_be_fitted():
|
||||
fit = fit_axis([(5.0, 300.0), (5.0, 310.0)])
|
||||
assert fit["status"] == "insufficient_reference_levels"
|
||||
assert "slope_raw_per_N" not in fit
|
||||
|
||||
|
||||
def test_no_finite_sample_is_reported_as_missing_data():
|
||||
fit = fit_axis([])
|
||||
assert fit["status"] == "no_data"
|
||||
|
||||
|
||||
def test_repeated_reference_levels_are_weighted_but_counted():
|
||||
fit = fit_axis([(0.0, 100.0), (5.0, 300.0), (5.0, 320.0), (10.0, 520.0)])
|
||||
|
||||
assert fit["window_count"] == 4
|
||||
assert fit["reference_min_N"] == 0.0 and fit["reference_max_N"] == 10.0
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(42.0)
|
||||
|
||||
|
||||
def test_the_forward_slope_is_not_the_reciprocal_of_the_inverse_fit():
|
||||
"""``N = gain*raw + offset`` and ``raw = a*N + b`` answer different questions."""
|
||||
import numpy as np
|
||||
|
||||
from orisys_autocal.session_review import _fit
|
||||
|
||||
raw = [100.0, 300.0, 520.0]
|
||||
loads = [0.0, 5.0, 10.0]
|
||||
forward = fit_axis(list(zip(loads, raw)))
|
||||
inverse = _fit([
|
||||
{"raw_mean": value, "reference_n": load, "status": "candidate", "trial_id": "t1"}
|
||||
for value, load in zip(raw, loads)
|
||||
])
|
||||
gain, _offset = np.polyfit(np.array(raw), np.array(loads), 1)
|
||||
|
||||
assert forward["slope_raw_per_N"] == pytest.approx(42.0)
|
||||
assert inverse["sensitivity_raw_per_n"] == pytest.approx(1.0 / gain)
|
||||
assert abs(forward["slope_raw_per_N"] - 1.0 / gain) > 1e-3
|
||||
|
||||
|
||||
def test_unloading_is_shown_but_never_fitted():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
windows = [
|
||||
_window(0.0, 100.0, branch="baseline"),
|
||||
_window(5.0, 300.0),
|
||||
_window(5.0, 340.0, branch="unload"),
|
||||
_window(0.0, 120.0, branch="unload"),
|
||||
]
|
||||
|
||||
rows = build_sensitivity(windows)
|
||||
|
||||
assert {row["component"] for row in rows} == {"FN", "FX", "FY"}
|
||||
fn = next(row for row in rows if row["component"] == "FN")
|
||||
assert fn["window_count"] == 2
|
||||
assert fn["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
# The unloading values are far higher; a fit that included them would not be 40.
|
||||
assert fn["endpoint_raw_per_N"] == pytest.approx(40.0)
|
||||
|
||||
|
||||
def test_endpoint_and_initial_step_use_the_declared_loads():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
windows = [
|
||||
_window(0.0, 100.0, branch="baseline"),
|
||||
_window(2.0, 180.0),
|
||||
_window(5.0, 300.0),
|
||||
]
|
||||
|
||||
fn = next(row for row in build_sensitivity(windows) if row["component"] == "FN")
|
||||
|
||||
# endpoint: (raw at the highest load - raw at zero) / highest load.
|
||||
assert fn["endpoint_raw_per_N"] == pytest.approx((300.0 - 100.0) / 5.0)
|
||||
# initial step: the lowest positive load minus zero.
|
||||
assert fn["initial_step_raw"] == pytest.approx(80.0)
|
||||
|
||||
|
||||
def test_a_missing_zero_load_leaves_the_derived_numbers_empty():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
fn = next(row for row in build_sensitivity([_window(5.0, 300.0), _window(10.0, 500.0)])
|
||||
if row["component"] == "FN")
|
||||
|
||||
assert fn["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert fn["endpoint_raw_per_N"] is None
|
||||
assert fn["initial_step_raw"] is None
|
||||
|
||||
|
||||
def test_confirmed_and_candidate_windows_are_never_fitted_together():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
windows = [
|
||||
_window(0.0, 100.0, branch="baseline"),
|
||||
_window(5.0, 300.0),
|
||||
_window(0.0, 100.0, branch="baseline", verified=True),
|
||||
_window(10.0, 500.0, verified=True),
|
||||
]
|
||||
|
||||
rows = build_sensitivity(windows)
|
||||
by_review = {}
|
||||
for row in rows:
|
||||
if row["component"] == "FN":
|
||||
by_review[row["review_status"]] = row
|
||||
|
||||
assert set(by_review) == {"candidate", "confirmed"}
|
||||
assert by_review["candidate"]["window_count"] == 2
|
||||
assert by_review["candidate"]["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert by_review["confirmed"]["window_count"] == 2
|
||||
assert by_review["confirmed"]["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""The candidate export, the reviewed export, and what happens when it fails."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
|
||||
from review_test_helpers import wait_review
|
||||
|
||||
from evidence_helpers import multipoint_session
|
||||
from orisys_autocal.summary_export import POINTER_NAME, load_summary
|
||||
|
||||
|
||||
def _pump(panel, timeout: float = 30.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while panel.summary_publishing and time.monotonic() < deadline:
|
||||
QCoreApplication.processEvents()
|
||||
time.sleep(0.005)
|
||||
assert not panel.summary_publishing, "the summary export never finished"
|
||||
|
||||
|
||||
def _finish_simulated_campaign(campaign) -> None:
|
||||
while campaign.phase in {"load", "hold"}:
|
||||
if campaign.phase == "load":
|
||||
branch, target = campaign._steps[campaign._step_index]
|
||||
campaign.confirm_reference(target)
|
||||
campaign.complete_hold(
|
||||
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
|
||||
)
|
||||
|
||||
|
||||
def _acquired_hashes(root) -> dict[str, str]:
|
||||
"""Hashes of the recorded evidence only: derived results are not evidence."""
|
||||
return {
|
||||
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file() and "derived" not in path.parts and path.name != POINTER_NAME
|
||||
}
|
||||
|
||||
|
||||
def test_closing_the_session_publishes_a_candidate_summary(make_window, tmp_path):
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0, 10.0))
|
||||
before = _acquired_hashes(root)
|
||||
window = make_window()
|
||||
panel = window.calibration_panel
|
||||
|
||||
assert panel.publish_summary_for(root)
|
||||
_pump(panel)
|
||||
|
||||
assert (root / POINTER_NAME).is_file()
|
||||
summary, pointer = load_summary(root)
|
||||
assert summary["formal_conclusion"] is False
|
||||
assert summary["windows"]
|
||||
assert all(row["review_status"] == "candidate" for row in summary["windows"])
|
||||
assert pointer["path"].startswith("derived/summary_")
|
||||
# Raw evidence is never rewritten by an export.
|
||||
assert _acquired_hashes(root) == before
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_failed_export_is_reported_and_can_be_retried(
|
||||
make_window, tmp_path, monkeypatch
|
||||
):
|
||||
import orisys_autocal.ui.calibration_panel as panel_module
|
||||
|
||||
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,))
|
||||
window = make_window()
|
||||
panel = window.calibration_panel
|
||||
|
||||
def explode(*args, **kwargs):
|
||||
raise RuntimeError("disk full")
|
||||
|
||||
monkeypatch.setattr(panel_module, "publish_summary", explode)
|
||||
assert panel.publish_summary_for(root)
|
||||
_pump(panel)
|
||||
|
||||
assert not (root / POINTER_NAME).exists()
|
||||
assert "disk full" in panel.result_label.text()
|
||||
assert panel.trial.status == "idle" # the acquisition result is untouched
|
||||
|
||||
monkeypatch.undo()
|
||||
assert panel.publish_summary_for(root)
|
||||
_pump(panel)
|
||||
assert (root / POINTER_NAME).is_file()
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_published_candidate_is_republished_when_the_review_is_saved(qt_app, tmp_path):
|
||||
from orisys_autocal.session_review import review_session
|
||||
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
|
||||
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0, 10.0))
|
||||
review = review_session(root)
|
||||
window = review["sites"][0]["windows"][2]
|
||||
selections = {f"{window['trial_id']}:{window['step_index']}": {
|
||||
"t_start_host_mono_ns": window["hold_start_host_mono_ns"] + 1,
|
||||
"t_end_host_mono_ns": window["hold_end_host_mono_ns"],
|
||||
"stability_verified": True,
|
||||
}}
|
||||
reviewed = review_session(root, selections=selections)
|
||||
dialog = RecordOverviewDialog(root, review=reviewed)
|
||||
try:
|
||||
dialog._save()
|
||||
wait_review(dialog)
|
||||
assert (root / POINTER_NAME).is_file()
|
||||
summary, pointer = load_summary(root)
|
||||
confirmed = [row for row in summary["windows"] if row["stability_verified"]]
|
||||
assert confirmed and confirmed[0]["review_status"] == "confirmed"
|
||||
assert pointer["summary_id"] == summary["summary_id"]
|
||||
assert summary["review_relative_path"].startswith("derived/review_")
|
||||
assert summary["sensitivity"]
|
||||
# A second explicit publish (the retry action) makes a new version.
|
||||
first_id = pointer["summary_id"]
|
||||
dialog._publish_summary()
|
||||
wait_review(dialog)
|
||||
second, pointer_again = load_summary(root)
|
||||
assert pointer_again["summary_id"] != first_id
|
||||
assert second["summary_id"] == pointer_again["summary_id"]
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_the_multipoint_end_of_campaign_starts_the_export(make_window, tmp_path):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel
|
||||
multipoint = panel.multipoint
|
||||
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
|
||||
directory = multipoint.campaign.start(
|
||||
tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="sensor", max_force_n=20, simulate=True,
|
||||
)
|
||||
_finish_simulated_campaign(multipoint.campaign)
|
||||
assert multipoint.campaign.phase == "done"
|
||||
|
||||
multipoint._poll()
|
||||
|
||||
_pump(panel)
|
||||
assert (directory / POINTER_NAME).is_file()
|
||||
summary, _pointer = load_summary(directory)
|
||||
assert summary["session_id"] == directory.name
|
||||
# A simulated campaign produces no frames, so its windows must not read as
|
||||
# measurements: they are excluded with the reason the review recorded.
|
||||
assert all(row["eligible"] is False for row in summary["windows"])
|
||||
assert all(row["excluded_reason"] == "no_frame_source" for row in summary["windows"])
|
||||
assert any(issue["reason"] == "no_frame_source" for issue in summary["issues"])
|
||||
window.close()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Locating a reviewed hold's frames in the existing recording."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from evidence_helpers import (
|
||||
BASE_NS,
|
||||
FRAMES_FIELDS,
|
||||
decoded_frame_number,
|
||||
force_row,
|
||||
frame_row,
|
||||
multipoint_session,
|
||||
write_capture,
|
||||
write_csv,
|
||||
write_json_file,
|
||||
)
|
||||
from orisys_autocal.session_review import review_session
|
||||
from orisys_autocal.video_review import (
|
||||
NO_FRAME_SOURCE,
|
||||
NO_VIDEO_FRAME,
|
||||
Location,
|
||||
in_window_frames,
|
||||
locate_window,
|
||||
open_window_video,
|
||||
)
|
||||
|
||||
|
||||
def _review(tmp_path) -> dict:
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,))
|
||||
return review_session(root)
|
||||
|
||||
|
||||
def _capture(review: dict):
|
||||
trial_id = review["trials"][0]["trial_id"]
|
||||
step = review["sites"][0]["windows"][1]["step_index"]
|
||||
return trial_id, step
|
||||
|
||||
|
||||
def test_a_reviewed_window_locates_its_own_first_video_frame(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
trial_id, step = _capture(review)
|
||||
|
||||
location = locate_window(review, trial_id=trial_id, step_index=step)
|
||||
|
||||
assert isinstance(location, Location)
|
||||
assert location.frame_id > 0
|
||||
assert location.segment == 1 and location.segment_frame == location.frame_id
|
||||
# The located picture is the one the frame index names.
|
||||
root = tmp_path / "rec"
|
||||
video = root / location.capture_directory / "raw_0001.mp4"
|
||||
assert decoded_frame_number(video, segment_frame=location.segment_frame) == location.segment_frame - 1
|
||||
|
||||
with open_window_video(review, trial_id=trial_id, step_index=step) as player:
|
||||
assert player.frame_count >= 1
|
||||
assert player.frame(0) is not None
|
||||
assert player.location.frame_id == location.frame_id
|
||||
assert player.frame(0).shape[:2] == (48, 64)
|
||||
|
||||
|
||||
def test_a_frame_outside_the_video_is_skipped_for_the_next_one(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
root = tmp_path / "rec"
|
||||
trial_id = review["trials"][0]["trial_id"]
|
||||
window = review["sites"][0]["windows"][1]
|
||||
capture = root / "sites" / "P1" / "repeat-001" / "capture"
|
||||
from evidence_helpers import read_csv
|
||||
|
||||
rows = read_csv(capture / "frames.csv")
|
||||
# The second hold starts at frame 4; take its first frame out of the video.
|
||||
first_of_hold = int(window["hold_start_host_mono_ns"])
|
||||
row = next(item for item in rows if int(item["t_host_mono_ns"]) >= first_of_hold)
|
||||
row["in_video"] = 0
|
||||
row["segment"] = 0
|
||||
row["segment_frame"] = 0
|
||||
skipped = int(row["frame_id"])
|
||||
write_csv(capture / "frames.csv", FRAMES_FIELDS, rows)
|
||||
|
||||
location = locate_window(review, trial_id=trial_id, step_index=window["step_index"])
|
||||
|
||||
assert isinstance(location, Location)
|
||||
assert location.frame_id == skipped + 1
|
||||
|
||||
|
||||
def test_no_capture_directory_is_a_reason_not_a_picture(tmp_path):
|
||||
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1,
|
||||
levels=(5.0,), with_capture=False)
|
||||
review = review_session(root)
|
||||
trial_id = review["trials"][0]["trial_id"]
|
||||
step = review["sites"][0]["windows"][1]["step_index"]
|
||||
|
||||
location = locate_window(review, trial_id=trial_id, step_index=step)
|
||||
|
||||
assert location == {"reason": NO_FRAME_SOURCE}
|
||||
with pytest.raises(ValueError, match=NO_FRAME_SOURCE):
|
||||
open_window_video(review, trial_id=trial_id, step_index=step)
|
||||
|
||||
|
||||
def test_a_hold_with_no_picture_in_the_video_has_no_frame(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
root = tmp_path / "rec"
|
||||
capture = root / "sites" / "P1" / "repeat-001" / "capture"
|
||||
from evidence_helpers import read_csv
|
||||
|
||||
rows = read_csv(capture / "frames.csv")
|
||||
for row in rows:
|
||||
row["in_video"] = 0
|
||||
row["segment"] = 0
|
||||
row["segment_frame"] = 0
|
||||
write_csv(capture / "frames.csv", FRAMES_FIELDS, rows)
|
||||
trial_id, step = _capture(review)
|
||||
|
||||
assert locate_window(review, trial_id=trial_id, step_index=step) == {
|
||||
"reason": NO_VIDEO_FRAME}
|
||||
|
||||
|
||||
def test_a_missing_segment_file_is_reported(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
root = tmp_path / "rec"
|
||||
for video in root.glob("sites/*/*/capture/raw_0001.mp4"):
|
||||
video.unlink()
|
||||
trial_id, step = _capture(review)
|
||||
|
||||
assert locate_window(review, trial_id=trial_id, step_index=step) == {
|
||||
"reason": NO_FRAME_SOURCE}
|
||||
|
||||
|
||||
def test_a_hold_split_across_segments_opens_the_right_one(tmp_path):
|
||||
"""Segment 2 restarts its own frame count; seeking must follow the index."""
|
||||
root = tmp_path / "rec"
|
||||
trial = root / "trial"
|
||||
start = BASE_NS + 10
|
||||
frames = [
|
||||
frame_row(1, start, segment=1, segment_frame=1),
|
||||
frame_row(2, start + 10, segment=1, segment_frame=2),
|
||||
frame_row(3, start + 20, segment=2, segment_frame=1),
|
||||
]
|
||||
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
|
||||
write_capture(trial, frames, forces, video_segments=2)
|
||||
write_json_file(root / "session.json", {"format_version": "autocal-session-1.0"})
|
||||
review = {
|
||||
"source_directory": str(root),
|
||||
"session_id": "s1",
|
||||
"trials": [{"trial_id": "t1", "directory": "trial"}],
|
||||
"sites": [{
|
||||
"site_id": "P1", "uv_xy_px": [1, 2],
|
||||
"windows": [{
|
||||
"trial_id": "t1", "step_index": 1,
|
||||
"hold_start_host_mono_ns": start, "hold_end_host_mono_ns": start + 100,
|
||||
"frame_source": "trial",
|
||||
"trace": [(start, 1, 1.0), (start + 10, 2, 2.0), (start + 20, 3, 3.0)],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
location = locate_window(review, trial_id="t1", step_index=1)
|
||||
|
||||
assert isinstance(location, Location) and location.segment == 1
|
||||
with open_window_video(review, trial_id="t1", step_index=1) as player:
|
||||
assert player.frame_count == 3
|
||||
assert player.frame(0) is not None
|
||||
# Frame 3 lives in the second container, at its own position 1.
|
||||
assert player.frame(2) is not None
|
||||
assert player.frames[2].segment == 2 and player.frames[2].segment_frame == 1
|
||||
|
||||
|
||||
def test_the_decoder_can_be_closed_and_reopened(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
trial_id, step = _capture(review)
|
||||
|
||||
first = open_window_video(review, trial_id=trial_id, step_index=step)
|
||||
assert first.frame(0) is not None
|
||||
first.close()
|
||||
second = open_window_video(review, trial_id=trial_id, step_index=step)
|
||||
try:
|
||||
assert second.frame(0) is not None
|
||||
finally:
|
||||
second.release()
|
||||
|
||||
|
||||
def test_an_unknown_trial_or_window_is_a_reason(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
trial_id, step = _capture(review)
|
||||
|
||||
assert locate_window(review, trial_id="missing", step_index=step) == {"reason": "trial_not_found"}
|
||||
assert locate_window(review, trial_id=trial_id, step_index=999) == {"reason": "window_not_found"}
|
||||
|
||||
|
||||
def test_the_frame_list_is_bounded_by_the_hold(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
trial_id, step = _capture(review)
|
||||
window = next(
|
||||
item for site in review["sites"] for item in site["windows"]
|
||||
if item["trial_id"] == trial_id and item["step_index"] == step
|
||||
)
|
||||
|
||||
frames = in_window_frames(review, trial_id=trial_id, step_index=step)
|
||||
|
||||
assert isinstance(frames, list) and frames
|
||||
assert all(
|
||||
window["hold_start_host_mono_ns"] <= frame.t_host_mono_ns < window["hold_end_host_mono_ns"]
|
||||
for frame in frames
|
||||
)
|
||||
assert all(frame.in_video for frame in frames)
|
||||
|
||||
|
||||
def test_decode_failure_is_reported_rather_than_showing_a_neighbour(tmp_path):
|
||||
review = _review(tmp_path)
|
||||
root = tmp_path / "rec"
|
||||
trial_id, step = _capture(review)
|
||||
location = locate_window(review, trial_id=trial_id, step_index=step)
|
||||
assert isinstance(location, Location)
|
||||
(root / location.capture_directory / "raw_0001.mp4").write_bytes(b"not a video")
|
||||
window = review["sites"][0]["windows"][1]
|
||||
player = open_window_video(review, trial_id=trial_id, step_index=window["step_index"])
|
||||
try:
|
||||
assert player.frame(0) is None
|
||||
assert player.position == -1
|
||||
finally:
|
||||
player.close()
|
||||
|
||||
def test_a_discarded_hold_attempt_cannot_be_played(tmp_path):
|
||||
"""A remeasured hold keeps its row id; its window must not be shown as evidence."""
|
||||
from evidence_helpers import HOLD_STRIDE_NS, Hold, multipoint_session
|
||||
|
||||
replaced = Hold(
|
||||
step_index=1, branch="load", target_load_n=5.0, reference_n=5.0,
|
||||
start_ns=BASE_NS + 200 * HOLD_STRIDE_NS,
|
||||
end_ns=BASE_NS + 200 * HOLD_STRIDE_NS + 4_000_000_000,
|
||||
complete=0,
|
||||
)
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,), extra_holds=[replaced])
|
||||
review = review_session(root)
|
||||
trial_id = review["trials"][0]["trial_id"]
|
||||
step = next(item for site in review["sites"] for item in site["windows"]
|
||||
if item["trial_id"] == trial_id)["step_index"]
|
||||
|
||||
assert locate_window(review, trial_id=trial_id, step_index=step, hold_id=4) == {
|
||||
"reason": "hold_not_complete"}
|
||||
assert locate_window(review, trial_id=trial_id, step_index=step, hold_id=99) == {
|
||||
"reason": "hold_not_found"}
|
||||
# The completed attempt still opens, and names the attempt it shows.
|
||||
location = locate_window(review, trial_id=trial_id, step_index=step, hold_id=1)
|
||||
assert isinstance(location, Location) and location.hold_id == 1
|
||||
@@ -0,0 +1,149 @@
|
||||
"""The review dialog that shows the recorded pictures beside the curve."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from review_test_helpers import wait_review
|
||||
|
||||
from evidence_helpers import multipoint_session
|
||||
from orisys_autocal.session_review import review_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reviewed(tmp_path):
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0, 10.0))
|
||||
return root, review_session(root)
|
||||
|
||||
|
||||
def _window(review: dict) -> dict:
|
||||
return review["sites"][0]["windows"][1]
|
||||
|
||||
|
||||
def test_the_dialog_shows_the_first_frame_of_the_selected_window(qt_app, reviewed):
|
||||
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
|
||||
|
||||
root, review = reviewed
|
||||
window = _window(review)
|
||||
dialog = VideoReviewDialog(
|
||||
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
|
||||
try:
|
||||
wait_review(dialog)
|
||||
assert dialog.video is not None
|
||||
assert dialog.location.frame_id > 0
|
||||
assert dialog.image.pixmap() is not None and not dialog.image.pixmap().isNull()
|
||||
assert "frame" in dialog.position.text().lower() or "帧" in dialog.position.text()
|
||||
assert dialog.slider.maximum() == dialog.video.frame_count - 1
|
||||
finally:
|
||||
dialog.close()
|
||||
assert dialog.video is None
|
||||
|
||||
|
||||
def test_stepping_moves_one_recorded_frame(qt_app, reviewed):
|
||||
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
|
||||
|
||||
root, review = reviewed
|
||||
window = _window(review)
|
||||
dialog = VideoReviewDialog(
|
||||
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
|
||||
try:
|
||||
wait_review(dialog)
|
||||
start = dialog.index
|
||||
dialog._step(1)
|
||||
wait_review(dialog)
|
||||
assert dialog.index == start + 1
|
||||
dialog._step(-1)
|
||||
wait_review(dialog)
|
||||
assert dialog.index == start
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_a_stale_frame_request_is_dropped(qt_app, reviewed):
|
||||
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
|
||||
|
||||
root, review = reviewed
|
||||
window = _window(review)
|
||||
dialog = VideoReviewDialog(
|
||||
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
|
||||
try:
|
||||
wait_review(dialog)
|
||||
dialog._request(0)
|
||||
dialog._request(1)
|
||||
wait_review(dialog)
|
||||
assert dialog.index == 1
|
||||
assert dialog.video.frame_count >= 2
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_a_recording_without_video_explains_itself(qt_app, tmp_path):
|
||||
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
|
||||
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,), with_capture=False)
|
||||
review = review_session(root)
|
||||
window = review["sites"][0]["windows"][1]
|
||||
|
||||
dialog = VideoReviewDialog(
|
||||
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
|
||||
try:
|
||||
wait_review(dialog)
|
||||
assert dialog.video is None
|
||||
assert not dialog.play_button.isEnabled()
|
||||
assert dialog.image.text()
|
||||
assert "no" in dialog.image.text().lower() or "没有" in dialog.image.text()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_the_hold_review_dialog_offers_the_video_for_the_selected_window(qt_app, reviewed):
|
||||
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
|
||||
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
|
||||
|
||||
root, review = reviewed
|
||||
dialog = SessionReviewDialog(root, review=review)
|
||||
try:
|
||||
assert dialog.video_button.isEnabled()
|
||||
opened = dialog._video()
|
||||
assert isinstance(opened, VideoReviewDialog)
|
||||
wait_review(opened)
|
||||
opened.close()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_the_record_overview_offers_the_video_action(qt_app, reviewed):
|
||||
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
|
||||
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
|
||||
|
||||
root, review = reviewed
|
||||
dialog = RecordOverviewDialog(root, review=review)
|
||||
try:
|
||||
actions = [action.objectName() for action in dialog.tools.menu().actions()]
|
||||
assert "overview_video" in actions
|
||||
dialog.table.clearSelection()
|
||||
dialog.table.setCurrentCell(-1, -1)
|
||||
assert dialog._video() is None
|
||||
assert dialog.notice.text()
|
||||
dialog.table.selectRow(0)
|
||||
opened = dialog._video()
|
||||
assert isinstance(opened, VideoReviewDialog)
|
||||
wait_review(opened)
|
||||
opened.close()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
def test_an_unclosed_trial_does_not_offer_the_video(qt_app, tmp_path):
|
||||
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
|
||||
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,), trial_status="incomplete")
|
||||
review = review_session(root)
|
||||
dialog = SessionReviewDialog(root, review=review)
|
||||
try:
|
||||
assert dialog._video() is None
|
||||
assert dialog.message.text()
|
||||
finally:
|
||||
dialog.close()
|
||||
Reference in New Issue
Block a user