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:
ykcheungab
2026-10-17 10:44:30 +08:00
parent d7540f52ec
commit 19426c3c4a
32 changed files with 5461 additions and 191 deletions
@@ -0,0 +1,225 @@
"""Capture honesty: one source session per recording, and one closing verdict.
Two defects motivated this module.
*The sink outlives the session.* A worker's record sinks are registered on the
worker object and survive ``start()``. When a camera is restarted while a
recording runs, the recorder keeps accepting packets whose ``session_id`` is the
new one and whose ``frame_id`` begins again, so two acquisitions are written into
one directory under one identity. The recorder cannot see this on its own: it
writes its own session id into ``forces.csv``. A :class:`CaptureGuard` sits
between the worker and the recorder, refuses the packet that would mix identities
and remembers why.
*"Stopped" is not "complete".* The multi-point campaign used to mark a repeat
complete from the operator's confirmation alone, without asking the writer what
happened. :func:`capture_verdict` is the single answer both recording paths use:
zero frames, a write error, a missing or undecodable video, more than one segment,
dropped data, a restarted source or an aborted operator all keep the evidence
incomplete, and each one names its reason.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
#: Reason codes. Tokens, not sentences: the UI localises them.
SOURCE_RESTARTED = "source_restarted"
SOURCE_STOPPED = "source_stopped"
NOT_RECORDED = "not_recorded"
WRITE_ERROR = "write_error"
ZERO_FRAMES = "zero_frames"
VIDEO_MISSING = "video_missing"
VIDEO_UNDECODABLE = "video_undecodable"
SEGMENT_COUNT = "segment_count"
DROPPED_DATA = "dropped_data"
DROPPED_VIDEO = "dropped_video"
CLOSING_TIMEOUT = "closing_timeout"
ABORTED = "aborted"
PENDING = "pending"
COMPLETE = "complete"
INCOMPLETE = "incomplete"
#: How long a closing writer may take before it is reported as incomplete.
CLOSING_TIMEOUT_S = 20.0
class CaptureGuard:
"""Sink wrapper: hands packets to the recorder only for the expected session."""
def __init__(self, *, expected_session_id: int, recorder) -> None:
self.expected_session_id = int(expected_session_id)
self.recorder = recorder
self.packets = 0
self.refused = 0
self.failed = False
self.reason = ""
def __call__(self, packet) -> None:
self.packets += 1
session = getattr(packet, "session_id", None)
if session is None or int(session) != self.expected_session_id:
# Do not forward: a row from another acquisition session has no
# identity inside this recording, and labelling it with the old
# session id would make the files lie.
self.refused += 1
self.failed = True
self.reason = SOURCE_RESTARTED
return
self.recorder.on_packet(packet)
def as_dict(self) -> dict:
return {
"expected_session_id": self.expected_session_id,
"packets": self.packets,
"refused": self.refused,
"failed": self.failed,
"reason": self.reason,
}
@dataclass(frozen=True)
class CaptureVerdict:
"""The one answer about whether a recording is usable evidence."""
status: str
reason: str = ""
report: dict | None = None
frames: int = 0
video_frames: int = 0
segments: int = 0
dropped_data: int = 0
dropped_video: int = 0
duration_s: float = 0.0
source_restarted: bool = False
@property
def complete(self) -> bool:
return self.status == COMPLETE
def probe_segments(directory: Path | None) -> tuple[bool, str]:
"""Decode the first frame of every segment. A non-empty file is not a video."""
if directory is None:
return False, "no directory"
segments = sorted(Path(directory).glob("raw_*.mp4"))
if not segments:
return False, "no segment file"
try:
import cv2
except ImportError: # pragma: no cover - cv2 is a recording dependency
# The recorder cannot have written an MP4 without cv2 either, so this is
# a broken environment rather than an undeodable recording; it is still
# not evidence and must not pass silently.
return False, "cv2 unavailable"
for path in segments:
capture = cv2.VideoCapture(str(path))
try:
if not capture.isOpened():
return False, path.name
ok, _image = capture.read()
if not ok:
return False, path.name
finally:
capture.release()
return True, ""
def capture_report_dict(recorder, guard: CaptureGuard | None = None) -> dict | None:
if recorder is None:
return None
report = recorder.report
directory = recorder.directory
payload: dict = {
"directory": str(directory) if directory is not None else "",
"state": recorder.state,
"guard": guard.as_dict() if guard is not None else None,
}
if report is not None:
payload.update({
"completed": bool(report.completed),
"stats": report.stats.as_dict(),
"problems": list(report.problems),
})
return payload
def capture_verdict(
recorder,
*,
complete: bool,
reason: str = "",
guard: CaptureGuard | None = None,
elapsed_s: float | None = None,
timeout_s: float = CLOSING_TIMEOUT_S,
probe_video: bool = True,
source_stopped: bool = False,
) -> CaptureVerdict:
"""Decide whether the closed recording is evidence, and say why not.
``pending`` is returned while the writer is still draining, so a caller can
keep polling instead of blocking its event loop. Both recording paths pass
their own abort reason: the operator's reason is more useful than a generic
token and is what the existing single-trial contract already records.
"""
if recorder is None:
# Nothing was recorded, so nothing is evidence. A simulation and a
# missing camera both land here; the caller decides whether a capture was
# expected at all, and the reason names the case.
return CaptureVerdict(INCOMPLETE, reason or NOT_RECORDED)
report = recorder.report
if report is None:
if elapsed_s is not None and elapsed_s > float(timeout_s):
return CaptureVerdict(
INCOMPLETE, CLOSING_TIMEOUT,
report=capture_report_dict(recorder, guard),
)
return CaptureVerdict(PENDING, report=capture_report_dict(recorder, guard))
stats = report.stats
base = dict(
report=capture_report_dict(recorder, guard),
frames=stats.frames,
video_frames=stats.video_frames,
segments=stats.segments,
dropped_data=stats.dropped_data,
dropped_video=stats.dropped_video,
duration_s=stats.duration_s,
source_restarted=bool(guard is not None and guard.failed),
)
def verdict(status: str, detail: str = "", **extra) -> CaptureVerdict:
return CaptureVerdict(status, detail, **{**base, **extra})
if guard is not None and guard.failed:
return verdict(INCOMPLETE, SOURCE_RESTARTED)
if source_stopped:
# The source stopped while holds were still being recorded: the frames
# that exist stop describing the end of the repeat.
return verdict(INCOMPLETE, SOURCE_STOPPED)
if not report.completed:
return verdict(INCOMPLETE, CLOSING_TIMEOUT)
if stats.failed or stats.write_errors:
return verdict(INCOMPLETE, WRITE_ERROR)
if not complete:
return verdict(INCOMPLETE, reason or ABORTED)
if stats.frames <= 0:
return verdict(INCOMPLETE, ZERO_FRAMES)
if stats.video_frames <= 0:
return verdict(INCOMPLETE, VIDEO_MISSING)
if stats.segments != 1:
return verdict(INCOMPLETE, SEGMENT_COUNT)
if stats.dropped_data:
return verdict(INCOMPLETE, DROPPED_DATA)
if stats.dropped_video:
return verdict(INCOMPLETE, DROPPED_VIDEO)
if probe_video:
ok, detail = probe_segments(report.directory)
if not ok:
payload = dict(base["report"] or {})
payload["probe_detail"] = detail
return verdict(INCOMPLETE, VIDEO_UNDECODABLE, report=payload)
return verdict(COMPLETE)
@@ -0,0 +1,327 @@
"""Frame and force identity inside one shared-recorder directory.
A recording directory holds ``frames.csv`` (the frame index: host acquisition
time, video position, drop state) and ``forces.csv`` (the force rows). The two
are joined by ``frame_id``. A row's position in either file is not an identity:
the acquisition queue can lose a row, and a camera that restarts begins its
frame numbers again, so pairing by line number silently moves a sample to the
wrong window.
The policy for a repeated ``frame_id`` inside one directory is to **exclude**
the samples it would pair and record the reason. Refusing the whole recording
would leave an operator unable to open the evidence that shows the problem;
inventing an order instead is what this module exists to stop.
Read-only: nothing here writes, and no Qt is imported.
"""
from __future__ import annotations
import csv
import math
from dataclasses import dataclass
from pathlib import Path
#: The force components the review and the summary tables report separately.
AXES: tuple[str, ...] = ("FN", "FX", "FY")
#: Raw column per component. Newton columns are not the same measurement.
VALUE_COLUMNS: dict[str, str] = {
"FN": "FN_raw",
"FX": "FX_raw",
"FY": "FY_raw",
}
DROPPED_REASON = "dropped_row"
DUPLICATE_REASON = "duplicate_frame_identity"
def _rows(path: Path) -> list[dict]:
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def _text(row: dict, key: str) -> str:
return str(row.get(key) or "").strip()
def _int(row: dict, key: str) -> int | None:
"""An integer cell, parsed exactly.
A host monotonic stamp is around 1e18 ns, which a float64 cannot hold to the
nanosecond: parsing through ``float`` would map neighbouring frames onto one
timestamp and make the identity join look like it worked. Integers are read
as integers; a decimal spelling is accepted only for its integer part.
"""
text = _text(row, key)
if not text:
return None
try:
return int(text)
except ValueError:
pass
try:
return int(float(text))
except (ValueError, OverflowError):
return None
def _flag(row: dict, key: str) -> bool:
"""A 0/1 column. A missing column is *unknown*, which callers decide about."""
return _text(row, key) == "1"
def _number(row: dict, key: str) -> float | None:
text = _text(row, key)
if not text:
return None
try:
value = float(text)
except ValueError:
return None
return value if math.isfinite(value) else None
@dataclass(frozen=True)
class FrameRow:
"""One line of ``frames.csv``, before any identity decision."""
frame_id: int
t_host_mono_ns: int | None
t_rel_s: float | None
time_basis: str
segment: int
segment_frame: int
#: ``None`` when the recording predates the ``in_video`` column.
in_video: bool | None
dropped: bool
@dataclass(frozen=True)
class Sample:
"""One force row paired with the acquisition time of its frame.
``values`` is aligned to :data:`AXES`. ``None`` means the cell was empty:
an axis without a raw channel is not zero.
"""
frame_id: int
t_host_mono_ns: int
row_index: int
valid: bool
values: tuple[float | None, float | None, float | None]
def value(self, axis: str) -> float | None:
return self.values[AXES.index(axis)]
def usable(self) -> bool:
"""A valid row with at least one finite raw component."""
return self.valid and any(value is not None for value in self.values)
@dataclass(frozen=True)
class JoinReport:
"""What the join had to give up, so a summary can show it rather than hide it."""
frames_total: int = 0
frames_dropped: int = 0
frames_without_time: int = 0
frames_not_in_video: int = 0
duplicate_frame_ids: int = 0
force_rows: int = 0
unjoined_force_rows: int = 0
@property
def reasons(self) -> tuple[str, ...]:
found: list[str] = []
if self.duplicate_frame_ids:
found.append(DUPLICATE_REASON)
if self.frames_dropped:
found.append(DROPPED_REASON)
return tuple(found)
def read_frame_index(directory: Path) -> list[FrameRow]:
"""Every parseable ``frames.csv`` row, in file order.
Unparseable rows are skipped rather than sorted out: a hand-edited or
truncated index must not crash a review, and the skipped count is visible in
the index length.
"""
path = Path(directory) / "frames.csv"
if not path.is_file():
return []
has_video_column = False
frames: list[FrameRow] = []
with path.open(encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
has_video_column = reader.fieldnames is not None and "in_video" in reader.fieldnames
for row in reader:
frame_id = _int(row, "frame_id")
if frame_id is None:
continue
frames.append(
FrameRow(
frame_id=frame_id,
t_host_mono_ns=_int(row, "t_host_mono_ns"),
t_rel_s=_number(row, "t_rel_s"),
time_basis=_text(row, "time_basis"),
segment=_int(row, "segment") or 0,
segment_frame=_int(row, "segment_frame") or 0,
in_video=_flag(row, "in_video") if has_video_column else None,
dropped=_flag(row, "dropped"),
)
)
return frames
def read_force_rows(directory: Path) -> list[dict]:
path = Path(directory) / "forces.csv"
return _rows(path) if path.is_file() else []
def unique_frames(frames: list[FrameRow]) -> tuple[dict[int, FrameRow], set[int]]:
"""``(frame_id -> row, duplicated ids)``. A repeated id has no single meaning."""
index: dict[int, FrameRow] = {}
duplicated: set[int] = set()
for frame in frames:
if frame.frame_id in index:
duplicated.add(frame.frame_id)
else:
index[frame.frame_id] = frame
for frame_id in duplicated:
index.pop(frame_id, None)
return index, duplicated
def joined_samples(
directory: Path,
*,
require_video: bool = False,
) -> tuple[list[Sample], JoinReport]:
"""Pair force rows to frame host times by ``frame_id``.
``require_video=True`` keeps only frames the recorder wrote into the video
container. ``False`` keeps every non-dropped frame, which is the legacy
``stable_means`` rule: that table never filtered on video membership and its
numbers must not change for that reason alone.
"""
frames = read_frame_index(directory)
index, duplicated = unique_frames(frames)
report = JoinReport(
frames_total=len(frames),
frames_dropped=sum(1 for frame in frames if frame.dropped),
frames_without_time=sum(
1 for frame in frames if not frame.dropped and frame.t_host_mono_ns is None
),
frames_not_in_video=sum(1 for frame in frames if frame.in_video is False),
duplicate_frame_ids=len(duplicated),
)
usable: dict[int, int] = {}
for frame in index.values():
if frame.dropped or frame.t_host_mono_ns is None:
continue
if require_video and frame.in_video is False:
continue
usable[frame.frame_id] = frame.t_host_mono_ns
samples: list[Sample] = []
rows = read_force_rows(directory)
unjoined = 0
for position, row in enumerate(rows, start=1):
frame_id = _int(row, "frame_id")
stamp = None if frame_id is None else usable.get(frame_id)
if stamp is None:
unjoined += 1
continue
samples.append(
Sample(
frame_id=frame_id,
t_host_mono_ns=stamp,
row_index=position,
valid=_text(row, "valid") in ("", "1"),
values=tuple(_number(row, VALUE_COLUMNS[axis]) for axis in AXES),
)
)
samples.sort(key=lambda sample: (sample.t_host_mono_ns, sample.row_index))
return samples, JoinReport(
frames_total=report.frames_total,
frames_dropped=report.frames_dropped,
frames_without_time=report.frames_without_time,
frames_not_in_video=report.frames_not_in_video,
duplicate_frame_ids=report.duplicate_frame_ids,
force_rows=len(rows),
unjoined_force_rows=unjoined,
)
def window_samples(
samples: list[Sample], start_ns: int, end_ns: int
) -> list[Sample]:
"""Samples whose acquisition time is in ``[start_ns, end_ns)``.
Sorting happens in :func:`joined_samples`, so a caller may bisect; a scan is
used here because a window is a small slice of the recording.
"""
return [sample for sample in samples if start_ns <= sample.t_host_mono_ns < end_ns]
def resolve_capture_directory(
root: Path, *, trial_directory: str, frame_source: str = ""
) -> Path | None:
"""Where a trial's frame files are: the trial directory or its ``capture/``.
The single-trial format keeps them beside ``reference.csv``; the multi-point
format nests them. The review already records the answer in
``frame_source``, and this function is the one place that falls back.
"""
root = Path(root)
source = str(frame_source or trial_directory or "")
if not source:
return None
candidate = (root / source).resolve()
if not candidate.is_relative_to(root.resolve()):
return None
if candidate.is_dir() and (candidate / "frames.csv").is_file():
return candidate
nested = candidate / "capture"
if (nested / "frames.csv").is_file():
return nested
return None
def reference_holds(directory: Path) -> list[dict]:
"""Every declared hold attempt in `reference.csv`, with its row ordinal.
`hold_id` is the data row's position, not a hash of the file: the remeasure
path rewrites the file in place (one flag changes), so a hash would change the
identity of a hold whose window did not move. A row whose times are unusable
is not a window and is skipped.
"""
import csv as _csv
path = Path(directory) / "reference.csv"
if not path.is_file():
return []
holds: list[dict] = []
with path.open(encoding="utf-8-sig", newline="") as handle:
for ordinal, row in enumerate(_csv.DictReader(handle), start=1):
start = _number(row, "t_start_host_mono_ns")
end = _number(row, "t_end_host_mono_ns")
if start is None or end is None or end <= start:
continue
step = row.get("step_index")
try:
step_index = int(float(step))
except (TypeError, ValueError):
step_index = -1
holds.append({
"hold_id": ordinal,
"step_index": step_index,
"branch": str(row.get("branch") or ""),
"target_load_n": _number(row, "target_load_n"),
"reference_load_n": _number(row, "F_ref_normal_N"),
"reference_source": str(row.get("reference_source") or ""),
"t_start_host_mono_ns": int(start),
"t_end_host_mono_ns": int(end),
"complete": str(row.get("hold_complete") or "0").strip() == "1",
})
return holds
+385 -80
View File
@@ -28,6 +28,16 @@ from pathlib import Path
from uuid import uuid4
from .calibration_session import write_json
from .capture_guard import (
CLOSING_TIMEOUT_S,
COMPLETE,
INCOMPLETE,
PENDING,
CaptureGuard,
NOT_RECORDED,
capture_verdict,
)
from .frames_index import read_frame_index, unique_frames
from .sensor.frames import PLATFORM_AXES, TOOL_AXIS
SENSOR_MODEL = "finger"
@@ -245,27 +255,29 @@ def stable_component_rows(
return []
with reference_path.open(encoding="utf-8-sig", newline="") as handle:
holds = list(csv.DictReader(handle))
stamps: list[int | None] = []
frames_path = repeat_dir / "capture" / "frames.csv"
if frames_path.is_file():
with frames_path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
# A gap is a hole in the force file, not a sample. A camera that
# is plugged back in starts its frame numbers again, so the time
# of a sample is the frame written with it, not the frame number.
if row.get("dropped") not in (None, "", "0"):
continue
text = str(row.get("t_host_mono_ns") or "").strip()
stamps.append(int(text) if text else None)
# Frames and forces meet by frame_id, never by line number: a dropped row or
# a camera that restarted would otherwise move a sample to another hold.
# A repeated frame_id has no single time, so its samples are left out rather
# than paired with an order this file cannot prove.
capture_dir = repeat_dir / "capture"
frame_rows = read_frame_index(capture_dir)
unique, _duplicated = unique_frames(frame_rows)
stamps: dict[int, int] = {
frame_id: row.t_host_mono_ns
for frame_id, row in unique.items()
if not row.dropped and row.t_host_mono_ns is not None
}
samples: list[tuple[int, str, tuple[float, float, float]]] = []
forces_path = repeat_dir / "capture" / "forces.csv"
forces_path = capture_dir / "forces.csv"
if forces_path.is_file():
with forces_path.open(encoding="utf-8-sig", newline="") as handle:
for index, row in enumerate(csv.DictReader(handle)):
if index >= len(stamps):
break
for row in csv.DictReader(handle):
components = _components(row)
stamp = stamps[index]
try:
frame_id = int(str(row.get("frame_id") or "").strip())
except ValueError:
continue
stamp = stamps.get(frame_id)
if components is None or stamp is None:
continue
samples.append((stamp, components[0], components[1]))
@@ -422,11 +434,33 @@ class Campaign:
self._pending: dict | None = None
self._single_site = False
self._finished_step_pending = False
#: The repeat record being recorded. Identity is carried explicitly: a
#: new repeat must never be described by ``trials[-1]``, which still
#: names the previous one while the new directory is being prepared.
self._trial_entry: dict | None = None
self._capture_session_id: int | None = None
self._guard: CaptureGuard | None = None
#: Closing state. A repeat is not complete until the writer has closed
#: its files and the report has been checked.
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report: dict | None = None
self._closing_entry: dict | None = None
self._closing_recorder = None
self._closing_guard: CaptureGuard | None = None
self._closing_complete = False
self._closing_terminal = False
self._closing_since: float | None = None
@property
def is_active(self) -> bool:
return self.phase in ACTIVE
@property
def busy(self) -> bool:
"""Active, or still closing the evidence of the repeat that just ended."""
return self.is_active or self.capture_state == "closing" or self.phase == "closing"
def add_site(
self,
site_id: str,
@@ -581,7 +615,7 @@ class Campaign:
simulate: bool,
min_force_n: float = 0.0,
) -> Path:
if self.is_active:
if self.is_active or self.capture_state == "closing" or self.phase == "closing":
raise RuntimeError("a multi-point acquisition is already running")
if sensor_model.strip() != SENSOR_MODEL:
raise ValueError("multi-point acquisition uses the finger product model")
@@ -610,6 +644,30 @@ class Campaign:
self._finished_step_pending = False
self._completed_holds = 0
self._trial_serial = 0
# A new session must not inherit anything from the previous one. The
# worker's record sinks outlive its own ``stop()``/``start()``, and the
# force-to-tool rotation belonged to a session that has ended.
self._pending_worker = None
self._image_worker = None
self._recorder = None
self._guard = None
self._capture_session_id = None
self._fps = 0.0
self._rotation = None
self._active = None
self._pending = None
self._hold_started_ns = None
self._trial_dir = None
self._trial_entry = None
self._source_stopped = False
self._closing_entry = None
self._closing_recorder = None
self._closing_complete = False
self._closing_terminal = False
self._closing_since = None
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report = None
self.directory = _session_directory(Path(root), self.sensor_model, self.sensor_sn)
self._document = {
"format_version": "autocal-multipoint-1.0",
@@ -726,25 +784,7 @@ class Campaign:
self.phase = "load"
return
self._finished_step_pending = False
self._close_repeat(complete=True)
self._repeat_index += 1
if self._single_site:
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": ""})
return
site = self.sites[self._site_index]
if self._repeat_index < site.repeats:
self._open_repeat()
self.phase = "load"
return
self._site_index += 1
self._repeat_index = 0
if self._site_index < len(self.sites):
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": self.sites[self._site_index].site_id})
return
self._finish_session("complete")
self.phase = "done"
self._begin_close(complete=True)
def confirm_liftoff(self) -> None:
if self.phase != "liftoff":
@@ -766,6 +806,8 @@ class Campaign:
self.phase = "load"
def stop(self, reason: str) -> None:
if self.phase == "closing" or self.capture_state == "closing":
return
if not self.is_active:
return
self.reason = reason or "stopped"
@@ -778,10 +820,12 @@ class Campaign:
self._reference_file.flush()
self._active = None
self._pending = None
if self._trial_dir is not None:
self._close_repeat(complete=False)
self._finish_session("incomplete")
self.phase = "aborted"
self._hold_started_ns = None
if self._trial_entry is not None or self._recorder is not None:
self._begin_close(complete=False, terminal=True)
else:
self._finish_session("incomplete")
self.phase = "aborted"
def can_remeasure_step(self) -> bool:
"""True while a hold can be dropped, or the step that just finished can be taken back."""
@@ -913,10 +957,11 @@ class Campaign:
"total": self._total_holds(),
"directory": str(self.directory) if self.directory else "",
"reason": self.reason,
"capture": self.capture_summary(),
}
def attach_capture(self, worker, *, fps: float) -> None:
"""Record images into the open trial. Does not move the platform."""
"""Record images into the open repeat. Does not move the platform."""
if self._trial_dir is None:
raise RuntimeError("no trial is open")
packet = None if worker is None else worker.latest()
@@ -924,7 +969,7 @@ class Campaign:
raise RuntimeError("start the camera and wait for a frame before live capture")
self._pending_worker = worker
self._fps = float(fps)
self._start_images(packet)
self._start_images(worker, self._trial_entry)
def _spec(self, site_id, x, y, z, levels, hold_s, repeats, max_force_n, min_force_n: float = 0.0) -> SiteSpec:
hold = _finite(hold_s, "hold time")
@@ -978,6 +1023,13 @@ class Campaign:
)
def _open_repeat(self) -> None:
"""Register the repeat first, then attach the recorder to that record.
The order is the contract: an explicit trial record exists before any
file is written for it, and capture is started *with* that record. The
previous implementation started the recorder first and then updated
``trials[-1]``, which still named the repeat that had just closed.
"""
site = self.sites[self._site_index]
self._steps = self._steps_for(site)
self._step_index = 0
@@ -987,8 +1039,10 @@ class Campaign:
folder = _path_part(site.site_id)
trial_id = f"{folder}-repeat-{repeat_number:03d}"
directory = self.directory / "sites" / folder / f"repeat-{repeat_number:03d}"
if directory.exists():
# A directory still being written must never be reused for a new attempt.
raise RuntimeError(f"the repeat directory already exists: {directory.name}")
directory.mkdir(parents=True)
self._trial_dir = directory
metadata = {
"trial_id": trial_id,
"site_id": site.site_id,
@@ -1013,9 +1067,30 @@ class Campaign:
"captured": False,
"reason": "simulation does not open a camera" if self.simulate else "camera not attached yet",
},
"capture_status": "idle",
"capture_reason": "",
"status": "incomplete",
"reason": "repeat has not finished",
}
write_json(directory / "session.json", metadata)
entry = {
"trial_id": trial_id,
"site_id": site.site_id,
"repeat_index": repeat_number,
"directory": f"sites/{folder}/repeat-{repeat_number:03d}",
"status": "incomplete",
"reason": "repeat has not finished",
"images": dict(metadata["images"]),
"capture_status": "idle",
"capture_reason": "",
}
self._document["trials"].append(entry)
self._trial_dir = directory
self._trial_entry = entry
self._source_stopped = False
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report = None
reference_file = (directory / "reference.csv").open("w", newline="", encoding="utf-8")
writer = csv.writer(reference_file)
writer.writerow(REFERENCE_HEADER)
@@ -1028,38 +1103,253 @@ class Campaign:
events.flush()
self._events_file = events
self._events = event_writer
self._event("trial_started", {"site_id": site.site_id, "repeat_index": self._repeat_index + 1})
self._save()
self._event("trial_started", {"site_id": site.site_id, "repeat_index": repeat_number})
if self._pending_worker is not None:
self._start_images(self._pending_worker.latest())
self._document["trials"].append({
"trial_id": trial_id,
"site_id": site.site_id,
"repeat_index": self._repeat_index + 1,
"directory": f"sites/{folder}/repeat-{repeat_number:03d}",
"status": "incomplete",
})
self._save()
self._start_images(self._pending_worker, entry)
def _close_repeat(self, *, complete: bool) -> None:
self._release_images()
if self._reference_file is not None:
self._reference_file.close()
self._reference_file = None
if self._events_file is not None:
self._events_file.close()
self._events_file = None
self._events = None
if self._trial_dir is None:
return
path = self._trial_dir / "session.json"
metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["status"] = "complete" if complete else "incomplete"
metadata["reason"] = "" if complete else self.reason
def _write_repeat_manifest(
self, entry: dict, *, status: str, capture_status: str
) -> None:
"""Update one repeat's own manifest. Never rebuild it from memory."""
path = self.directory / entry["directory"] / "session.json"
try:
metadata = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
metadata = {}
metadata.update({
"status": status,
"reason": entry.get("reason", ""),
"capture_status": capture_status,
"capture_reason": entry.get("capture_reason", ""),
"images": entry.get("images") or metadata.get("images"),
})
if entry.get("capture_report") is not None:
metadata["capture_report"] = entry["capture_report"]
write_json(path, metadata)
self._document["trials"][-1]["status"] = metadata["status"]
self._document["trials"][-1]["reason"] = metadata["reason"]
def _close_repeat_files(self) -> None:
if self._reference_file is not None:
try:
self._reference_file.close()
finally:
self._reference_file = None
if self._events_file is not None:
try:
self._events_file.close()
finally:
self._events_file = None
self._events = None
self._trial_dir = None
self._trial_entry = None
def _begin_close(self, *, complete: bool, terminal: bool = False) -> None:
"""Stop the recorder without waiting for it, then let :meth:`poll` finish.
The Qt thread cannot sit in ``recorder.stop(wait=True)``: the writer may
need seconds to flush a video, and the operator has to keep seeing the
window. The repeat's own record is written again with the final status
only after the report exists, so an interrupted close leaves an
explicitly incomplete manifest rather than a false complete one.
"""
entry = self._trial_entry
if entry is None and self._recorder is None:
if terminal:
self._finish_session("incomplete")
self.phase = "aborted"
return
self._closing_entry = entry
self._closing_complete = bool(complete)
self._closing_terminal = bool(terminal)
recorder, guard, worker = self._recorder, self._guard, self._image_worker
self._recorder = None
self._guard = None
self._image_worker = None
self._closing_guard = guard
if worker is not None and guard is not None:
worker.remove_sink(guard)
self._close_repeat_files()
if recorder is not None:
recorder.stop(wait=False)
self._closing_recorder = recorder
self._closing_since = time.perf_counter()
self.capture_state = "closing"
self.capture_reason = ""
if entry is not None:
entry["capture_status"] = "closing"
self._write_repeat_manifest(entry, status="incomplete", capture_status="closing")
self._save()
if recorder is None:
self._finalise_close()
else:
# The acquisition step is over even though the writer is still
# draining: a second confirmation must not start another hold.
self.phase = "closing"
def _finalise_close(self) -> None:
entry = self._closing_entry
if entry is None:
self.capture_state = "idle"
return
recorder = self._closing_recorder
if recorder is None and self.simulate:
# A simulated campaign is not a recording: its steps can be complete,
# but there is no capture evidence to call complete.
self._finish_repeat(
entry,
status=COMPLETE if self._closing_complete else INCOMPLETE,
capture_status=NOT_RECORDED,
capture_reason=NOT_RECORDED,
report=None,
state=NOT_RECORDED,
)
return
elapsed = None if self._closing_since is None else time.perf_counter() - self._closing_since
verdict = capture_verdict(
recorder,
complete=self._closing_complete,
reason=self.reason,
guard=self._closing_guard,
elapsed_s=elapsed,
source_stopped=self._source_stopped,
)
if verdict.status == PENDING:
self.capture_state = "closing"
return
status = COMPLETE if (
self._closing_complete and verdict.status == COMPLETE
) else INCOMPLETE
self._finish_repeat(
entry,
status=status,
capture_status=verdict.status,
capture_reason=verdict.reason,
report=verdict.report,
state=verdict.status,
)
def _finish_repeat(
self,
entry: dict,
*,
status: str,
capture_status: str,
capture_reason: str,
report: dict | None,
state: str,
) -> None:
"""Write the repeat's final record, then move the campaign on."""
entry["status"] = status
entry["reason"] = "" if status == COMPLETE else (self.reason or capture_reason)
entry["capture_status"] = capture_status
entry["capture_reason"] = capture_reason
entry["capture_report"] = report
try:
self._write_repeat_manifest(entry, status=status, capture_status=capture_status)
except OSError:
entry["status"] = INCOMPLETE
entry["reason"] = "could not write the repeat manifest"
self.capture_state = state
self.capture_reason = capture_reason
self.capture_report = report
self._source_stopped = False
self._closing_entry = None
self._closing_recorder = None
self._closing_guard = None
self._closing_since = None
self._save()
if self._closing_terminal:
self._finish_session("incomplete")
self.phase = "aborted"
return
self._advance_after_repeat()
def _advance_after_repeat(self) -> None:
"""Move to the next repeat, the next point, or the end of the campaign."""
self._repeat_index += 1
if self._single_site:
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": ""})
return
site = self.sites[self._site_index]
if self._repeat_index < site.repeats:
self._open_repeat()
self.phase = "load"
return
self._site_index += 1
self._repeat_index = 0
if self._site_index < len(self.sites):
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": self.sites[self._site_index].site_id})
return
self._finish_session(self._session_status())
self.phase = "done"
def poll(self) -> dict:
"""Drive a closing writer and report the capture state. Never blocks."""
guard = self._guard
if guard is not None and self.phase in {"load", "hold"}:
worker = self._image_worker
if guard.failed:
self.stop("the image source restarted")
elif worker is not None and not getattr(worker, "is_running", True):
# The camera went away mid-repeat; the frames already written no
# longer describe the holds that follow, so the repeat cannot be
# called complete evidence.
self._source_stopped = True
self.stop("the image source stopped")
if self.capture_state == "closing":
self._finalise_close()
return {
"phase": self.phase,
"capture_state": self.capture_state,
"capture_reason": self.capture_reason,
}
def flush_close(self, *, timeout: float = CLOSING_TIMEOUT_S) -> None:
"""Bounded wait used only when the window is closing."""
if self.capture_state != "closing":
return
if self._closing_recorder is not None:
self._closing_recorder.stop(wait=True, timeout=timeout)
self._finalise_close()
@property
def closing(self) -> bool:
return self.capture_state == "closing"
@property
def capture_ready(self) -> bool:
"""Whether a hold may be confirmed for the open repeat."""
if self._trial_entry is None:
return False
if self.simulate:
return True
return (
self.capture_state == "recording"
and self._guard is not None
and not self._guard.failed
)
def capture_summary(self) -> dict:
"""What the operator needs about the current recording, without reading disk."""
recorder = self._recorder or self._closing_recorder
stats = recorder.stats() if recorder is not None else None
entry = self._trial_entry or self._closing_entry
return {
"state": self.capture_state,
"reason": self.capture_reason,
"ready": self.capture_ready,
"frames": int(getattr(stats, "frames", 0) or 0),
"video_frames": int(getattr(stats, "video_frames", 0) or 0),
"dropped_data": int(getattr(stats, "dropped_data", 0) or 0),
"dropped_video": int(getattr(stats, "dropped_video", 0) or 0),
"duration_s": float(getattr(stats, "duration_s", 0.0) or 0.0),
"directory": str(self.directory / entry["directory"]) if (
entry is not None and self.directory is not None
) else "",
"trial_id": str(entry.get("trial_id", "")) if entry is not None else "",
}
def _finish_session(self, status: str) -> None:
self._document["status"] = status
@@ -1163,12 +1453,18 @@ class Campaign:
self._events.writerow(row)
self._events_file.flush()
def _start_images(self, packet) -> None:
def _start_images(self, worker, entry: dict | None = None) -> None:
"""Start the recorder **for the repeat that is open**, with its identity."""
entry = self._trial_entry if entry is None else entry
if entry is None or entry is not self._trial_entry:
raise RuntimeError("start capture for the repeat that is open")
packet = None if worker is None else worker.latest()
if packet is None or packet.capture_mono_ns is None:
raise RuntimeError("start the camera and wait for a frame before live capture")
if self._recorder is not None:
raise RuntimeError("a capture is already running for this repeat")
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
self._release_images()
recorder = ExperimentRecorder(
RecordConfig(fps=self._fps, segment_seconds=24 * 3600.0, preserve_all_frames=True)
)
@@ -1178,19 +1474,28 @@ class Campaign:
metadata={"sync": SYNC, "images_only": True},
directory_name="capture",
)
self._pending_worker.add_sink(recorder.on_packet)
guard = CaptureGuard(expected_session_id=int(packet.session_id), recorder=recorder)
worker.add_sink(guard)
self._recorder = recorder
self._image_worker = self._pending_worker
trial = self._document["trials"][-1]
trial["images"] = {"captured": True, "directory": f"{trial['directory']}/capture"}
self._guard = guard
self._image_worker = worker
self._capture_session_id = int(packet.session_id)
self.capture_state = "recording"
self.capture_reason = ""
entry["images"] = {"captured": True, "directory": f"{entry['directory']}/capture"}
entry["capture_session_id"] = self._capture_session_id
entry["capture_status"] = "recording"
self._write_repeat_manifest(entry, status="incomplete", capture_status="recording")
self._save()
def _release_images(self) -> None:
recorder, worker = self._recorder, self._image_worker
"""Stop the capture synchronously. Used only by the bounded shutdown path."""
recorder, guard, worker = self._recorder, self._guard, self._image_worker
self._recorder = None
self._guard = None
self._image_worker = None
if worker is not None and recorder is not None:
worker.remove_sink(recorder.on_packet)
if worker is not None and guard is not None:
worker.remove_sink(guard)
if recorder is not None:
recorder.stop(wait=True)
@@ -14,6 +14,7 @@ from uuid import uuid4
import numpy as np
from .calibration_session import write_json
from .frames_index import read_frame_index, unique_frames
def _rows(path: Path) -> list[dict]:
@@ -158,25 +159,27 @@ def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
bases: set[str] = set()
in_video = 0
segments: set[int] = set()
for row in _rows(directory / "frames.csv"):
if row.get("dropped") != "0" or not row.get("t_host_mono_ns"):
frame_rows = read_frame_index(directory)
unique, duplicated = unique_frames(frame_rows)
if duplicated:
# A repeated frame number has no single acquisition time. Its samples are
# left out instead of being paired with an order the files cannot prove.
source["duplicate_frame_ids"] = len(duplicated)
for frame in frame_rows:
if frame.dropped or frame.t_host_mono_ns is None or frame.in_video is False:
continue
if row.get("in_video") != "1":
if frame.frame_id in duplicated:
continue
segment = int(row["segment"])
segment = frame.segment
video = directory / f"raw_{segment:04d}.mp4"
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
source["reason"] = "missing_video_segment"
return source
frame_id = int(row["frame_id"])
if frame_id in frames:
raise ValueError("duplicate frame identity in a recorded frame source")
frames[frame_id] = int(row["t_host_mono_ns"])
bases.add(row.get("time_basis", ""))
if row.get("in_video") == "1":
in_video += 1
if row.get("segment"):
segments.add(int(row["segment"]))
frames[frame.frame_id] = frame.t_host_mono_ns
bases.add(frame.time_basis)
in_video += 1
if segment:
segments.add(segment)
if not frames:
source["reason"] = "no_recorded_video_frames"
return source
@@ -268,6 +271,11 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
issues.append({"trial_id": trial_id, "reason": "incomplete_trial",
"detail": str(trial.get("reason") or record.get("status") or "")})
source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs)
if source.get("duplicate_frame_ids"):
issues.append({
"trial_id": trial_id, "reason": "duplicate_frame_identity",
"detail": f"{source['duplicate_frame_ids']} frame id(s) repeated",
})
samples = source["samples"]
unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS]
if not source["recorded"]:
@@ -435,13 +443,16 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
or metadata.get("uv_definition") != manifest["uv_definition"]):
raise ValueError(f"trial identity or completion mismatch: {trial_id}")
frames = {}
for row in _rows(path / "frames.csv"):
if row["dropped"] != "0" or not row["t_host_mono_ns"]:
unique, duplicated = unique_frames(read_frame_index(path))
if duplicated:
issues.append({
"trial_id": trial_id, "reason": "duplicate_frame_identity",
"detail": f"{len(duplicated)} frame id(s) repeated",
})
for frame_id, frame in unique.items():
if frame.dropped or frame.t_host_mono_ns is None:
continue
frame_id = int(row["frame_id"])
if frame_id in frames:
raise ValueError(f"duplicate frame identity in {trial_id}")
frames[frame_id] = int(row["t_host_mono_ns"])
frames[frame_id] = frame.t_host_mono_ns
samples = []
for row in _rows(path / "forces.csv"):
if row["valid"] != "1" or not row["FN_raw"]:
+655
View File
@@ -0,0 +1,655 @@
"""Operator summary: two traceable tables built from recorded evidence.
The summary answers the operator's question — what did each measured point do
under each declared load — without becoming a second calibration. It is built
from the review's hold windows (one definition of "an attempt") plus the raw
components in the frame source, joined by frame identity.
Two tables, both versioned:
``baseline_load_data.csv``
one row per actual hold attempt, with its identity, window method, sample
counts and the three raw component means. A rejected attempt stays visible.
``sensitivity_values.csv``
one row per point, trial, axis and review set, with the forward regression
``raw = a * reference + b``. ``a`` is not ``1 / gain`` of the review's inverse
fit; the two answer different questions and are kept apart by name.
Nothing here writes files (see :mod:`summary_export`) and nothing calls the SDK.
Method and unit rules, fixed here so a reader can recompute every number:
* window: ``hold_second_half_v1`` for an unverified candidate, the reviewer's
exact interval (``review_selection_v1``) once confirmed. The latter half is the
settling-time rule the legacy ``stable_means.csv`` already used; no auto
"stable" flag is ever written.
* samples: raw components only. A missing cell stays empty; no zero is invented
and no Newton value is mixed in. Standard deviation is the sample deviation
(``ddof=1``); fewer than two samples leave it empty.
* fit: ordinary least squares of ``raw`` on the *actual* reference N, within one
trial, over zero-load and load windows at equal weight. Unload windows are
reported (they show hysteresis) but never enter this fit. At least two
distinct reference loads are required.
"""
from __future__ import annotations
import csv
import hashlib
import json
import math
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from uuid import uuid4
import numpy as np
from .frames_index import (
AXES,
joined_samples,
reference_holds,
resolve_capture_directory,
)
from .session_review import review_session
SCHEMA_VERSION = "autocal-summary-1.0"
HOLD_WINDOW_METHOD = "hold_second_half_v1"
REVIEW_WINDOW_METHOD = "review_selection_v1"
LEGACY_WINDOW_METHOD = "legacy_stable_means_v1"
FIT_METHOD = "raw_on_reference_ols_v1"
UNIT = "raw"
FORMAL_CONCLUSION = False
#: Reasons a row can be listed but not used. Tokens, localised by the report.
REASON_INCOMPLETE_TRIAL = "incomplete_trial"
REASON_NO_FRAME_SOURCE = "no_frame_source"
REASON_CAPTURE_INCOMPLETE = "capture_incomplete"
REASON_NOT_RECORDED = "not_recorded"
REASON_TIME_BASIS = "time_basis_not_comparable"
REASON_SIMULATION = "simulation"
REASON_REMEASURED = "remeasured"
REASON_INCOMPLETE_HOLD = "incomplete_hold"
REASON_INSUFFICIENT_SAMPLES = "insufficient_samples"
REASON_LEGACY = "legacy_no_window"
REASON_NO_DATA = "no_data"
REASON_FEWER_LEVELS = "insufficient_reference_levels"
REASON_FLAT = "flat_signal"
REASON_NO_ZERO = "missing_zero_load"
REASON_NO_HIGH = "missing_highest_load"
#: Review window statuses that mean "the evidence itself is missing".
_BLOCKING_STATUSES = {
"no_frame_source", "time_basis_not_comparable", "simulation",
"incomplete_trial", "insufficient_samples",
}
#: Capture lifecycles that mean "there is no usable video for this repeat".
_BLOCKING_CAPTURE = {"incomplete", "missing", "not_recorded"}
def _number(value) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _selections_hash(selections: dict) -> str:
return _sha256_text(json.dumps(selections, sort_keys=True, ensure_ascii=False))
def _versions() -> dict:
found = {}
for package in ("orisys", "orisys-autocal", "orisys-app-shared", "numpy"):
try:
found[package] = version(package)
except PackageNotFoundError:
found[package] = "unavailable"
return found
def _capture_directory(root: Path, trial: dict, window: dict) -> Path | None:
return resolve_capture_directory(
root,
trial_directory=str(trial.get("directory") or ""),
frame_source=str(window.get("frame_source") or ""),
)
def _trial_by_id(review: dict) -> dict[str, dict]:
return {trial["trial_id"]: trial for trial in review["trials"]}
def _capture_status(root: Path, trial: dict) -> tuple[str, str]:
"""``(status, reason)`` from the repeat record, defaulting to unknown.
The capture lifecycle is written into the repeat's own ``session.json`` (the
root manifest entry carries only what the campaign knew at the time), so the
repeat record is read when the entry has nothing newer. A legacy record with
no lifecycle field at all is *unknown*: ``images.captured`` says a recorder
was attached once, not that the video is usable.
"""
status = str(trial.get("capture_status") or "").strip()
if status:
return status, str(trial.get("capture_reason") or "")
record: dict = {}
path = root / str(trial.get("directory") or "") / "session.json"
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
record = {}
status = str(record.get("capture_status") or "").strip()
if status:
return status, str(record.get("capture_reason") or "")
images = trial.get("images") or record.get("images") or {}
if images.get("captured") is True:
return "unknown", ""
return "missing", str(images.get("reason") or "")
def _axis_stats(values_by_axis: dict[str, list[float]]) -> dict:
result = {}
for axis in AXES:
values = [value for value in values_by_axis[axis] if value is not None]
result[axis] = {
"n": len(values),
"mean": float(np.mean(values)) if values else None,
"std": float(np.std(values, ddof=1)) if len(values) >= 2 else None,
"min": float(np.min(values)) if values else None,
"max": float(np.max(values)) if values else None,
}
return result
def _window_interval(window: dict, hold: dict | None) -> tuple[int, int, str]:
"""The interval the summary averages, and the method that chose it."""
if window.get("stability_verified"):
return (
int(window["t_start_host_mono_ns"]),
int(window["t_end_host_mono_ns"]),
REVIEW_WINDOW_METHOD,
)
start = int(window["hold_start_host_mono_ns"])
end = int(window["hold_end_host_mono_ns"])
if hold is not None:
start, end = hold["t_start_host_mono_ns"], hold["t_end_host_mono_ns"]
return start + (end - start) // 2, end, HOLD_WINDOW_METHOD
def _frame_samples(
cache: dict[Path, object], directory: Path, *, require_video: bool
) -> list:
key = (directory, require_video)
if key not in cache:
cache[key] = joined_samples(directory, require_video=require_video)[0]
return cache[key]
def _window_samples(
cache: dict, directory: Path, *, require_video: bool, start_ns: int, end_ns: int
) -> tuple[dict[str, list[float]], int, int]:
values: dict[str, list[float]] = {axis: [] for axis in AXES}
total = 0
excluded = 0
for sample in _frame_samples(cache, directory, require_video=require_video):
if not start_ns <= sample.t_host_mono_ns < end_ns:
continue
total += 1
if not sample.valid:
excluded += 1
continue
for axis in AXES:
values[axis].append(sample.value(axis))
return values, total, excluded
def _excluded_reason(window: dict, trial_status: str, capture: tuple[str, str]) -> str:
status = str(window.get("status") or "")
if trial_status != "complete":
return REASON_INCOMPLETE_TRIAL
# The review's own status is more specific than the capture lifecycle: a
# simulated campaign reports "no frame source", not "capture incomplete".
if status in _BLOCKING_STATUSES:
return status
if capture[0] in _BLOCKING_CAPTURE:
if capture[0] == "not_recorded":
return REASON_NOT_RECORDED
if capture[0] == "missing":
return REASON_NO_FRAME_SOURCE
return REASON_CAPTURE_INCOMPLETE
if status != "candidate":
return str(status or REASON_NO_DATA)
return ""
def build_windows(root: Path, review: dict) -> list[dict]:
"""One row per actual hold attempt, including the ones that were replaced."""
root = Path(root).resolve()
trials = _trial_by_id(review)
by_trial_holds = {
trial_id: reference_holds(root / trial["directory"])
for trial_id, trial in trials.items()
}
windows_by_trial: dict[str, list[dict]] = {}
for site in review["sites"]:
for window in site["windows"]:
windows_by_trial.setdefault(window["trial_id"], []).append(window)
cache: dict = {}
rows: list[dict] = []
for trial in review["trials"]:
trial_id = trial["trial_id"]
trial_status = str(trial.get("status") or "")
capture = _capture_status(root, trial)
holds = by_trial_holds.get(trial_id, [])
windows = windows_by_trial.get(trial_id, [])
matched: set[tuple[int, int]] = set()
for hold in holds:
window = next(
(
item for item in windows
if item["step_index"] == hold["step_index"]
and int(item["hold_start_host_mono_ns"]) == hold["t_start_host_mono_ns"]
),
None,
)
if window is not None:
matched.add((hold["step_index"], hold["t_start_host_mono_ns"]))
directory = _capture_directory(root, trial, window or {})
require_video = bool(review.get("source_format"))
if window is not None and hold["complete"]:
start, end, method = _window_interval(window, hold)
if directory is None:
values = {axis: [] for axis in AXES}
total = excluded = 0
else:
values, total, excluded = _window_samples(
cache, directory, require_video=require_video,
start_ns=start, end_ns=end,
)
else:
values = {axis: [] for axis in AXES}
total = excluded = 0
start, end = hold["t_start_host_mono_ns"], hold["t_end_host_mono_ns"]
method = HOLD_WINDOW_METHOD
stats = _axis_stats(values)
window_status = str(window.get("status", "") if window is not None else "")
verified = bool(window.get("stability_verified")) if window is not None else False
if not hold["complete"]:
reason = REASON_REMEASURED if any(
other["step_index"] == hold["step_index"] and other["complete"]
for other in holds
) else REASON_INCOMPLETE_HOLD
elif window is None:
reason = REASON_INCOMPLETE_TRIAL if trial_status != "complete" else REASON_NO_DATA
elif not verified and window_status == "candidate" and stats["FN"]["n"] < 2:
reason = REASON_INSUFFICIENT_SAMPLES
else:
reason = _excluded_reason(window, trial_status, capture)
rows.append({
"schema_version": SCHEMA_VERSION,
"summary_id": "", # filled by build_summary
"session_id": review["session_id"],
"trial_id": trial_id,
"hold_id": hold["hold_id"],
"site_id": str(trial.get("site_id") or ""),
"point": "",
"repeat_index": trial.get("repeat_index"),
"step_index": hold["step_index"],
"branch": hold["branch"],
"source_file": f"{trial['directory']}/reference.csv",
"U": None,
"V": None,
"mapping_sha256": (review.get("uv_definition") or {}).get("mapping_sha256"),
"reference_source": hold["reference_source"],
"target_load_n": hold["target_load_n"],
"reference_load_n": hold["reference_load_n"],
"t_start_host_mono_ns": start,
"t_end_host_mono_ns": end,
"time_basis": _time_basis(window),
"window_method": method,
"stability_verified": verified,
"samples_total": total,
"samples_valid": total - excluded,
"samples_excluded": excluded,
"axis_samples": {axis: stats[axis]["n"] for axis in AXES},
"FN_mean": stats["FN"]["mean"],
"FX_mean": stats["FX"]["mean"],
"FY_mean": stats["FY"]["mean"],
"FN_std": stats["FN"]["std"],
"FX_std": stats["FX"]["std"],
"FY_std": stats["FY"]["std"],
"unit": UNIT,
"trial_status": trial_status,
"capture_status": capture[0],
"capture_reason": capture[1],
"review_status": (
"confirmed" if verified else window_status or "unavailable"
),
"eligible": not reason,
"excluded_reason": reason,
})
for window in windows:
if (int(window["step_index"]), int(window["hold_start_host_mono_ns"])) in matched:
continue
rows.append(_orphan_window_row(root, review, trial, window, cache))
# UV coordinates belong to the site record, not to the attempt.
sites = {site["site_id"]: site for site in review["sites"]}
for row in rows:
site = sites.get(row["site_id"])
uv = site.get("uv_xy_px") if site else None
if isinstance(uv, (list, tuple)) and len(uv) == 2:
row["U"], row["V"] = float(uv[0]), float(uv[1])
row["point"] = f"U {row['U']:g}, V {row['V']:g}" if row["U"] is not None else ""
return rows
def _time_basis(window: dict | None) -> str:
if not window:
return ""
bases = window.get("time_bases") or []
return bases[0] if len(bases) == 1 else ""
def _orphan_window_row(
root: Path, review: dict, trial: dict, window: dict, cache: dict
) -> dict:
"""A reviewed window whose reference row no longer matches: still listed."""
capture = _capture_status(root, trial)
return {
"schema_version": SCHEMA_VERSION, "summary_id": "",
"session_id": review["session_id"], "trial_id": trial["trial_id"],
"hold_id": -1, "site_id": str(trial.get("site_id") or ""), "point": "",
"repeat_index": trial.get("repeat_index"), "step_index": int(window["step_index"]),
"branch": str(window.get("branch") or ""), "source_file": f"{trial['directory']}/reference.csv",
"U": None, "V": None,
"mapping_sha256": (review.get("uv_definition") or {}).get("mapping_sha256"),
"reference_source": str(window.get("reference_source") or ""),
"target_load_n": window.get("target_load_n"),
"reference_load_n": window.get("reference_n"),
"t_start_host_mono_ns": int(window["t_start_host_mono_ns"]),
"t_end_host_mono_ns": int(window["t_end_host_mono_ns"]),
"time_basis": _time_basis(window), "window_method": HOLD_WINDOW_METHOD,
"stability_verified": False, "samples_total": 0, "samples_valid": 0,
"samples_excluded": 0, "axis_samples": {axis: 0 for axis in AXES},
"FN_mean": None, "FX_mean": None, "FY_mean": None,
"FN_std": None, "FX_std": None, "FY_std": None, "unit": UNIT,
"trial_status": str(trial.get("status") or ""),
"capture_status": capture[0], "capture_reason": capture[1],
"review_status": str(window.get("status") or ""),
"eligible": False, "excluded_reason": REASON_NO_DATA,
}
def fit_axis(
points: list[tuple[float, float]],
) -> dict:
"""Forward OLS ``raw = slope * reference + intercept`` for one axis."""
finite = [(x, y) for x, y in points if math.isfinite(x) and math.isfinite(y)]
loads = sorted({x for x, _ in finite})
if not finite:
return {"status": REASON_NO_DATA}
if len(loads) < 2:
return {"status": REASON_FEWER_LEVELS}
x = np.array([value for value, _ in finite], dtype=float)
y = np.array([value for _, value in finite], dtype=float)
x_mean, y_mean = float(x.mean()), float(y.mean())
sxx = float(((x - x_mean) ** 2).sum())
if sxx <= 0:
return {"status": REASON_FEWER_LEVELS}
slope = float(((x - x_mean) * (y - y_mean)).sum() / sxx)
intercept = y_mean - slope * x_mean
predicted = slope * x + intercept
residuals = y - predicted
sse = float((residuals ** 2).sum())
sst = float(((y - y_mean) ** 2).sum())
r2 = 1.0 - sse / sst if sst > 0 else None
return {
"status": "available" if r2 is not None else REASON_FLAT,
"slope_raw_per_N": slope,
"intercept_raw": intercept,
"R2": r2,
"rmse_raw": float(np.sqrt(sse / len(y))),
"reference_min_N": min(loads),
"reference_max_N": max(loads),
"window_count": len(finite),
}
def _load_means(rows: list[dict], axis: str) -> dict[float, float]:
"""Mean raw value per declared reference load, over the eligible rows."""
groups: dict[float, list[float]] = {}
for row in rows:
load = row["reference_load_n"]
value = row[f"{axis}_mean"]
if load is None or value is None or not row["eligible"]:
continue
groups.setdefault(float(load), []).append(float(value))
return {load: float(np.mean(values)) for load, values in groups.items()}
def _endpoint(rows: list[dict], axis: str) -> float | None:
values = _load_means(rows, axis)
zero = values.get(0.0)
positive = [load for load in values if load > 0]
if zero is None or not positive:
return None
high = max(positive)
return (values[high] - zero) / high
def _initial_step(rows: list[dict], axis: str) -> float | None:
values = _load_means(rows, axis)
zero = values.get(0.0)
positive = sorted(load for load in values if load > 0)
if zero is None or not positive:
return None
return values[positive[0]] - zero
def build_sensitivity(windows: list[dict]) -> list[dict]:
"""Per point, trial, axis and review set: a fit that can be recomputed."""
rows: list[dict] = []
groups: dict[tuple, list[dict]] = {}
for window in windows:
if not window["eligible"]:
continue
if window["branch"] not in {"zero", "baseline", "load"}:
continue # unloading is shown, never fitted
groups.setdefault(
(window["site_id"], window["trial_id"], bool(window["stability_verified"])),
[],
).append(window)
for (site_id, trial_id, verified), group in sorted(groups.items()):
for axis in AXES:
key = f"{axis}_mean"
points = [
(float(row["reference_load_n"]), float(row[key]))
for row in group
if row["reference_load_n"] is not None and row[key] is not None
]
fit = fit_axis(points)
rows.append({
"schema_version": SCHEMA_VERSION,
"summary_id": "",
"session_id": group[0]["session_id"],
"site_id": site_id,
"trial_id": trial_id,
"repeat_index": group[0]["repeat_index"],
"step_index": "",
"component": axis,
"review_status": "confirmed" if verified else "candidate",
"method": FIT_METHOD,
"slope_raw_per_N": fit.get("slope_raw_per_N"),
"intercept_raw": fit.get("intercept_raw"),
"R2": fit.get("R2"),
"rmse_raw": fit.get("rmse_raw"),
"endpoint_raw_per_N": _endpoint(group, axis),
"initial_step_raw": _initial_step(group, axis),
"reference_min_N": fit.get("reference_min_N"),
"reference_max_N": fit.get("reference_max_N"),
"window_count": fit.get("window_count", 0),
"status": fit["status"],
"reason": "" if fit["status"] == "available" else fit["status"],
})
return rows
def aggregate_sensitivity(rows: list[dict]) -> list[dict]:
"""Mean and sample deviation of the per-trial slopes. Not a joint fit."""
groups: dict[tuple[str, str], list[dict]] = {}
for row in rows:
if row["status"] != "available" or row["slope_raw_per_N"] is None:
continue
groups.setdefault((row["site_id"], row["component"]), []).append(row)
result = []
for (site_id, component), group in sorted(groups.items()):
slopes = [float(row["slope_raw_per_N"]) for row in group]
result.append({
"site_id": site_id,
"component": component,
"trials": len(slopes),
"slope_mean_raw_per_N": float(np.mean(slopes)),
"slope_std_raw_per_N": float(np.std(slopes, ddof=1)) if len(slopes) >= 2 else None,
"window_count": sum(int(row["window_count"] or 0) for row in group),
})
return result
def build_summary(
root: Path,
*,
review: dict | None = None,
selections: dict | None = None,
review_path: Path | None = None,
) -> dict:
"""Build the summary object. Pure: no file is written and no input changes."""
root = Path(root).resolve()
if review is None:
review = review_session(root, selections=selections)
windows = build_windows(root, review)
sensitivity = build_sensitivity(windows)
summary_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}_{uuid4().hex[:8]}"
for row in [*windows, *sensitivity]:
row["summary_id"] = summary_id
trials = review["trials"]
capture = []
for trial in trials:
status, reason = _capture_status(root, trial)
capture.append({
"trial_id": trial["trial_id"],
"site_id": trial.get("site_id"),
"trial_status": str(trial.get("status") or ""),
"capture_status": status,
"capture_reason": reason,
"capture_report": trial.get("capture_report"),
})
missing = sorted({
row["excluded_reason"] for row in windows if row["excluded_reason"]
})
review_ref = ""
if review_path is not None:
try:
review_ref = str(Path(review_path).resolve().relative_to(root)).replace("\\", "/")
except ValueError:
review_ref = ""
return {
"schema_version": SCHEMA_VERSION,
"summary_id": summary_id,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"software": _versions(),
"source_directory": str(root),
"session_id": review["session_id"],
"source_format": review.get("source_format") or "autocal-session-1.0",
"sensor_model": review.get("sensor_model"),
"sensor_sn": review.get("sensor_sn"),
"units": {
"raw": UNIT,
"reference": "N",
"note": "raw components are not newtons and are not mixed with them",
},
"methods": {
"window_unverified": HOLD_WINDOW_METHOD,
"window_confirmed": REVIEW_WINDOW_METHOD,
"fit": FIT_METHOD,
"endpoint": "highest_load_minus_zero_over_its_reference",
"initial_step": "lowest_positive_mean_minus_zero_mean",
},
"formal_conclusion": FORMAL_CONCLUSION,
"input_sha256": review.get("input_sha256") or {},
"review_relative_path": review_ref,
"selections_sha256": _selections_hash(review.get("selections") or {}),
"capture": capture,
"completion": review.get("completion") or [],
"issues": review.get("issues") or [],
"missing_reasons": missing,
"quality": {
str(site.get("site_id")): site.get("quality")
for site in review.get("sites") or []
},
"windows": windows,
"sensitivity": sensitivity,
"sensitivity_summary": aggregate_sensitivity(sensitivity),
}
def legacy_window_rows(path: Path, *, summary_id: str = "") -> list[dict]:
"""Rows of an old ``stable_means.csv``, marked as having no window evidence.
The historical table has no host times, no standard deviation and no hold
identity, and its per-axis outlier rule is not the new table's. Its numbers
are kept, labelled, and excluded from every fit rather than reinterpreted.
"""
path = Path(path)
if not path.is_file():
return []
rows = []
with path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
rows.append({
"schema_version": SCHEMA_VERSION,
"summary_id": summary_id,
"session_id": "",
"trial_id": "",
"hold_id": -1,
"site_id": str(row.get("site_id") or ""),
"point": "",
"repeat_index": _number(row.get("repeat_index")),
"step_index": _number(row.get("step_index")),
"branch": str(row.get("branch") or ""),
"source_file": str(path.name),
"U": _number(row.get("U")),
"V": _number(row.get("V")),
"mapping_sha256": None,
"reference_source": str(row.get("负载类型") or ""),
"target_load_n": _number(row.get("target_load_n")),
"reference_load_n": _number(row.get("target_load_n")),
"t_start_host_mono_ns": None,
"t_end_host_mono_ns": None,
"time_basis": "",
"window_method": LEGACY_WINDOW_METHOD,
"stability_verified": False,
"samples_total": _number(row.get("samples")) or 0,
"samples_valid": _number(row.get("samples")) or 0,
"samples_excluded": 0,
"axis_samples": {
axis: int(_number(row.get(f"{axis}_mean")) is not None) for axis in AXES
},
"FN_mean": _number(row.get("FN_mean")),
"FX_mean": _number(row.get("FX_mean")),
"FY_mean": _number(row.get("FY_mean")),
"FN_std": None, "FX_std": None, "FY_std": None,
"unit": str(row.get("unit") or UNIT),
"trial_status": "legacy",
"capture_status": "unknown",
"capture_reason": "",
"review_status": "legacy",
"eligible": False,
"excluded_reason": REASON_LEGACY,
})
return rows
@@ -0,0 +1,390 @@
"""Publish one summary: four files, one atomic pointer, no partial result.
The export writes the whole set into a unique temporary directory, verifies what
it wrote, renames that directory into place, and only then replaces
``summary-latest.json`` through an atomic temp-file replace. A failure anywhere
before the rename leaves the previous pointer untouched; the acquisition state is
never rolled back because an export failed.
Nothing is published when the recorded inputs changed since the summary was
built: the numbers would no longer be reproducible from the files on disk.
"""
from __future__ import annotations
import csv
import hashlib
import html
import json
import threading
from pathlib import Path
from uuid import uuid4
from .calibration_session import write_json
from .session_review import review_session
from .summary import SCHEMA_VERSION, build_summary
POINTER_NAME = "summary-latest.json"
POINTER_FORMAT = "autocal-summary-pointer-1.0"
EXPORT_FORMAT = "autocal-summary-export-1.0"
BASELINE_HEADER = (
"schema_version", "summary_id", "session_id", "trial_id", "hold_id", "site_id",
"point", "repeat_index", "step_index", "branch", "source_file",
"U", "V", "mapping_sha256",
"reference_source", "target_load_n", "reference_load_n",
"t_start_host_mono_ns", "t_end_host_mono_ns", "time_basis",
"window_method", "stability_verified",
"samples_total", "samples_valid", "samples_excluded",
"samples_FN", "samples_FX", "samples_FY",
"FN_mean", "FX_mean", "FY_mean", "FN_std", "FX_std", "FY_std", "unit",
"trial_status", "capture_status", "review_status", "eligible", "excluded_reason",
)
SENSITIVITY_HEADER = (
"schema_version", "summary_id", "session_id", "site_id", "trial_id",
"repeat_index", "component", "review_status", "method",
"slope_raw_per_N", "intercept_raw", "R2", "rmse_raw",
"endpoint_raw_per_N", "initial_step_raw",
"reference_min_N", "reference_max_N", "window_count", "status", "reason",
)
#: One publish per session root at a time.
_LOCKS: dict[str, threading.Lock] = {}
_LOCKS_GUARD = threading.Lock()
class SummaryExportError(RuntimeError):
"""The summary was not published; the previous pointer still stands."""
def _lock_for(root: Path) -> threading.Lock:
key = str(Path(root).resolve())
with _LOCKS_GUARD:
return _LOCKS.setdefault(key, threading.Lock())
def _cell(value) -> str:
"""Full-precision cell. A missing value is empty, never zero."""
if value is None or value == "":
return ""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, float):
return repr(value)
return str(value)
def _row_to_cells(row: dict, header: tuple[str, ...]) -> list[str]:
cells = []
for name in header:
if name.startswith("samples_") and name[8:] in {"FN", "FX", "FY"}:
cells.append(_cell((row.get("axis_samples") or {}).get(name[8:])))
else:
cells.append(_cell(row.get(name)))
return cells
def _write_csv(path: Path, header: tuple[str, ...], rows: list[dict]) -> None:
with path.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.writer(handle)
writer.writerow(header)
for row in rows:
writer.writerow(_row_to_cells(row, header))
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _quality_section(summary: dict) -> str:
rows = []
for site_id, quality in sorted((summary.get("quality") or {}).items()):
if not quality:
rows.append(
"<tr><td>%s</td><td colspan=\"4\">%s</td></tr>"
% (html.escape(site_id), html.escape("not available"))
)
continue
validation = quality.get("validation") or {}
rows.append(
"<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>" % (
html.escape(site_id),
html.escape(str(len(quality.get("repeatability") or []))),
html.escape(str(len(quality.get("hysteresis") or []))),
html.escape(str(validation.get("evaluated_windows", 0))),
html.escape(str(validation.get("rmse_n") if validation.get("rmse_n") is not None else "—")),
)
)
return (
"<h2>Repeatability, hysteresis and held-out validation (existing review)</h2>"
"<table><thead><tr><th>Point</th><th>Repeat groups</th><th>Hysteresis rows</th>"
"<th>Held-out windows</th><th>Held-out RMSE (N)</th></tr></thead><tbody>%s</tbody></table>"
% "".join(rows)
)
def _sensitivity_table(summary: dict) -> str:
rows = []
for row in summary.get("sensitivity") or []:
cells = [
html.escape(str(row.get("site_id") or "")),
html.escape(str(row.get("component") or "")),
html.escape(str(row.get("review_status") or "")),
html.escape(_cell(row.get("slope_raw_per_N"))),
html.escape(_cell(row.get("intercept_raw"))),
html.escape(_cell(row.get("R2"))),
html.escape(_cell(row.get("endpoint_raw_per_N"))),
html.escape(_cell(row.get("initial_step_raw"))),
html.escape(str(row.get("window_count") or 0)),
html.escape(str(row.get("status") or "")),
]
rows.append("<tr>%s</tr>" % "".join(f"<td>{cell}</td>" for cell in cells))
return (
"<h2>Sensitivity per point, trial and component (raw per N)</h2>"
"<p>Method <code>%s</code>: ordinary least squares of the raw window mean on the "
"actual reference load. Unloading is not fitted. This is not a joint three-axis "
"fit and not a physical accuracy claim.</p>"
"<table><thead><tr><th>Point</th><th>Component</th><th>Review set</th><th>Slope</th>"
"<th>Intercept</th><th>R2</th><th>Endpoint</th><th>Initial step</th>"
"<th>Windows</th><th>Status</th></tr></thead><tbody>%s</tbody></table>"
% (html.escape(str((summary.get("methods") or {}).get("fit", ""))), "".join(rows))
)
def _window_table(summary: dict) -> str:
rows = []
for row in summary.get("windows") or []:
cells = [
html.escape(str(row.get("site_id") or "")),
html.escape(str(row.get("trial_id") or "")),
html.escape(str(row.get("hold_id") or "")),
html.escape(str(row.get("branch") or "")),
html.escape(_cell(row.get("reference_load_n"))),
html.escape(_cell(row.get("FN_mean"))),
html.escape(_cell(row.get("FX_mean"))),
html.escape(_cell(row.get("FY_mean"))),
html.escape(str(row.get("samples_valid") or 0)),
html.escape(str(row.get("window_method") or "")),
html.escape("yes" if row.get("eligible") else "no"),
html.escape(str(row.get("excluded_reason") or "")),
]
rows.append("<tr>%s</tr>" % "".join(f"<td>{cell}</td>" for cell in cells))
return (
"<h2>Every hold attempt (raw components)</h2>"
"<p>An attempt is listed even when it is excluded, so nothing disappears quietly. "
"A repeated attempt is excluded with <code>remeasured</code>.</p>"
"<table><thead><tr><th>Point</th><th>Trial</th><th>Hold</th><th>Branch</th>"
"<th>Reference N</th><th>FN</th><th>FX</th><th>FY</th><th>Samples</th>"
"<th>Window</th><th>Eligible</th><th>Excluded reason</th></tr></thead>"
"<tbody>%s</tbody></table>" % "".join(rows)
)
def _evidence_links(summary: dict) -> str:
links = []
for trial in summary.get("capture") or []:
trial_id = str(trial.get("trial_id") or "")
report = trial.get("capture_report") or {}
directory = str(report.get("directory") or "")
if directory:
links.append(
"<li>%s: %s — <code>%s</code></li>"
% (
html.escape(trial_id),
html.escape(str(trial.get("capture_status") or "")),
html.escape(directory),
)
)
else:
links.append(
"<li>%s: %s</li>"
% (html.escape(trial_id), html.escape(str(trial.get("capture_status") or "")))
)
return "<h2>Evidence</h2><ul>%s</ul>" % "".join(links)
def render_html(summary: dict) -> str:
"""One self-contained page; it loads nothing and escapes every value."""
completion = summary.get("completion") or []
done = sum(1 for row in completion if row.get("complete"))
capture = summary.get("capture") or []
incomplete_capture = [row["trial_id"] for row in capture
if row.get("capture_status") in {"incomplete", "missing"}]
pending = sum(1 for row in summary.get("windows") or []
if row.get("review_status") == "candidate" and row.get("eligible"))
missing = ", ".join(summary.get("missing_reasons") or []) or "none"
body = [
"<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\">",
"<title>Autocal summary %s</title>" % html.escape(str(summary.get("summary_id") or "")),
"<style>body{font-family:sans-serif;margin:24px;color:#1b1b1b}"
"table{border-collapse:collapse;margin:8px 0 20px}"
"th,td{border:1px solid #c9c9c9;padding:3px 8px;font-size:13px;text-align:left}"
"th{background:#eee}code{background:#f4f4f4;padding:0 3px}"
".warn{color:#a33}</style></head><body>",
"<h1>Autocal recording summary</h1>",
"<p><b>Model</b> %s · <b>SN</b> %s · <b>Session</b> %s</p>" % (
html.escape(str(summary.get("sensor_model") or "—")),
html.escape(str(summary.get("sensor_sn") or "—")),
html.escape(str(summary.get("session_id") or "—")),
),
"<p><b>Points complete</b> %d / %d · <b>Capture incomplete</b> %d · "
"<b>Windows awaiting review</b> %d · <b>Missing data</b> %s</p>" % (
done, len(completion), len(incomplete_capture), pending, html.escape(missing),
),
"<p class=\"warn\">This page states measurements and their gaps. It is not a "
"pass/fail certificate, and it does not validate physical accuracy.</p>",
_sensitivity_table(summary),
_window_table(summary),
_quality_section(summary),
_evidence_links(summary),
"<p>Schema %s · generated %s</p>" % (
html.escape(str(summary.get("schema_version") or SCHEMA_VERSION)),
html.escape(str(summary.get("generated_at") or "")),
),
"</body></html>",
]
return "\n".join(body)
def _write_export(directory: Path, summary: dict) -> dict[str, str]:
directory.mkdir(parents=True, exist_ok=False)
(directory / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
(directory / "summary.html").write_text(render_html(summary), encoding="utf-8")
_write_csv(directory / "baseline_load_data.csv", BASELINE_HEADER, summary.get("windows") or [])
_write_csv(directory / "sensitivity_values.csv", SENSITIVITY_HEADER, summary.get("sensitivity") or [])
hashes = {}
for name in ("summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"):
path = directory / name
if not path.is_file():
raise SummaryExportError(f"the export is missing {name}")
hashes[name] = _sha256_file(path)
# Read the JSON back: a summary that cannot be parsed is not a published result.
json.loads((directory / "summary.json").read_text(encoding="utf-8"))
return hashes
def _pointer_for(root: Path, summary: dict, directory: Path, hashes: dict[str, str]) -> dict:
return {
"format_version": POINTER_FORMAT,
"export_format": EXPORT_FORMAT,
"summary_id": summary["summary_id"],
"generated_at": summary.get("generated_at"),
"path": str(directory.relative_to(root)).replace("\\", "/"),
"input_sha256": summary.get("input_sha256") or {},
"selections_sha256": summary.get("selections_sha256"),
"files": hashes,
}
def _read_pointer(root: Path) -> dict:
path = root / POINTER_NAME
if not path.is_file():
return {}
try:
pointer = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return pointer if isinstance(pointer, dict) else {}
def verify_inputs(root: Path, summary: dict) -> None:
"""Refuse to publish numbers the files on disk can no longer reproduce."""
fresh = review_session(root)
if fresh.get("input_sha256") != summary.get("input_sha256"):
raise SummaryExportError(
"recorded inputs changed since this summary was built; nothing was published"
)
if fresh.get("session_id") != summary.get("session_id"):
raise SummaryExportError("the summary belongs to a different session")
def publish_summary(
root: Path,
summary: dict,
*,
verify: bool = True,
cancelled=None,
) -> Path:
"""Write, verify, rename, then move the pointer. Returns the published directory."""
root = Path(root).resolve()
if not (root / "session.json").is_file():
raise SummaryExportError(f"not a session directory: {root}")
if not summary.get("summary_id"):
raise SummaryExportError("the summary has no id")
with _lock_for(root):
if cancelled is not None and cancelled():
raise SummaryExportError("the export was cancelled before it started")
if verify:
verify_inputs(root, summary)
previous = _read_pointer(root)
generated = str(summary.get("generated_at") or "")
if previous.get("generated_at") and generated and generated < str(previous["generated_at"]):
raise SummaryExportError(
"a newer summary is already published; this one would move the pointer back"
)
derived = root / "derived"
derived.mkdir(parents=True, exist_ok=True)
temporary = derived / f".tmp-summary-{uuid4().hex[:8]}"
target = derived / f"summary_{summary['summary_id']}"
if target.exists():
raise SummaryExportError(f"this summary is already published: {target.name}")
try:
hashes = _write_export(temporary, summary)
if cancelled is not None and cancelled():
raise SummaryExportError("the export was cancelled before publishing")
temporary.rename(target)
except Exception as exc:
if temporary.exists():
# Best effort: the failure is already being reported, and a
# leftover temp directory must not replace that reason.
for leftover in sorted(temporary.rglob("*"), reverse=True):
try:
leftover.unlink()
except OSError:
pass
try:
temporary.rmdir()
except OSError:
pass
if isinstance(exc, SummaryExportError):
raise
raise SummaryExportError(f"could not publish the summary: {exc}") from exc
pointer = _pointer_for(root, summary, target, hashes)
write_json(root / POINTER_NAME, pointer)
return target
def publish_reviewed_summary(
root: Path,
review: dict,
*,
review_path: Path | None = None,
) -> Path:
"""Publish a summary of an existing (already saved) review snapshot."""
summary = build_summary(Path(root), review=review, review_path=review_path)
return publish_summary(root, summary)
def load_summary(root: Path) -> tuple[dict | None, dict]:
"""``(summary, pointer)`` for the last published result, or ``(None, {})``."""
root = Path(root).resolve()
pointer = _read_pointer(root)
if not pointer:
return None, {}
if pointer.get("format_version") != POINTER_FORMAT:
raise SummaryExportError("unsupported pointer format")
path = (root / str(pointer.get("path") or "")) / "summary.json"
if not path.is_file():
raise SummaryExportError("the pointer names a summary that is not there")
for name, digest in (pointer.get("files") or {}).items():
candidate = path.parent / name
if not candidate.is_file() or _sha256_file(candidate) != digest:
raise SummaryExportError(f"the published summary was modified: {name}")
return json.loads(path.read_text(encoding="utf-8")), pointer
+79 -34
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from .calibration_session import CalibrationSession, write_json
from .capture_guard import CaptureGuard, capture_verdict, PENDING
from .sensor.uv_flat import Site
@@ -59,6 +60,10 @@ class ManualTrial:
self.completed_steps: list[dict] = []
self.reason = ""
self.recorder: ExperimentRecorder | None = None
self.guard: CaptureGuard | None = None
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report: dict | None = None
self.directory: Path | None = None
self.steps: list[tuple[str, float]] = []
self.step_index = 0
@@ -66,6 +71,7 @@ class ManualTrial:
self.aborted = False
self.reference_alignment_possible = False
self._last_confirm_ns: int | None = None
self._closing_since_ns: int | None = None
self._reference_file = None
self._reference_writer = None
self._active_reference: tuple[int, str, float, float, int] | None = None
@@ -192,8 +198,14 @@ class ManualTrial:
self.step_index = 0
self.aborted = False
self._last_confirm_ns = None
self._closing_since_ns = None
self.status = "recording"
self.worker.add_sink(recorder.on_packet)
self.capture_state = "recording"
self.capture_reason = ""
self.capture_report = None
guard = CaptureGuard(expected_session_id=int(packet.session_id), recorder=recorder)
self.guard = guard
self.worker.add_sink(guard)
self._event("trial_started", {"site_uv_xy_px": [site.u_px, site.v_px]})
return directory
@@ -346,13 +358,20 @@ class ManualTrial:
)
self.reason = reason
self.aborted = aborted
self.worker.remove_sink(self.recorder.on_packet)
if self.guard is not None:
self.worker.remove_sink(self.guard)
else:
self.worker.remove_sink(self.recorder.on_packet)
self.recorder.stop(wait=False)
self._closing_since_ns = time.perf_counter_ns()
self.status = "closing"
self.capture_state = "closing"
def poll(self) -> str:
if self.status == "recording" and self.recorder is not None:
if not self.worker.is_running or not self.recorder.is_recording:
if self.guard is not None and self.guard.failed:
self.finish(aborted=True, reason="the image source restarted")
elif not self.worker.is_running or not self.recorder.is_recording:
self.finish(aborted=True, reason="input or writer stopped")
elif self.holding and self.remaining_s <= 0:
deadline = self._last_confirm_ns + round(self.hold_s * 1e9)
@@ -360,36 +379,62 @@ class ManualTrial:
if self._reference_error:
self.finish(aborted=True, reason=self._reference_error)
if self.status == "closing" and self.recorder is not None:
report = self.recorder.report
if report is not None:
self.status = (
"complete"
if (
report.ok
and report.stats.frames > 0
and report.stats.video_frames > 0
and report.stats.segments == 1
and report.stats.dropped_data == 0
and report.stats.dropped_video == 0
and not self.aborted
and self.next_step is None
)
else "incomplete"
)
self.entry.update({
"status": self.status,
"reason": self.reason or ("" if self.status == "complete" else "recording integrity check failed"),
"steps": list(self.completed_steps),
complete = not self.aborted and self.next_step is None
elapsed = (
None if self._closing_since_ns is None
else (time.perf_counter_ns() - self._closing_since_ns) / 1e9
)
verdict = capture_verdict(
self.recorder,
complete=complete,
reason=self.reason,
guard=self.guard,
elapsed_s=elapsed,
)
if verdict.status == PENDING:
return self.status
self.status = verdict.status
self.capture_state = verdict.status
self.capture_reason = verdict.reason
self.capture_report = verdict.report
self.entry.update({
"status": self.status,
"reason": self.reason or ("" if self.status == "complete" else verdict.reason),
"steps": list(self.completed_steps),
"capture_status": verdict.status,
"capture_reason": verdict.reason,
"capture_report": verdict.report,
})
try:
trial_path = self.directory / "session.json"
document = json.loads(trial_path.read_text(encoding="utf-8"))
document.update({
"trial_status": self.status,
"steps": self.completed_steps,
"trial_reason": self.entry["reason"],
"capture_status": verdict.status,
"capture_reason": verdict.reason,
"capture_report": verdict.report,
})
try:
trial_path = self.directory / "session.json"
document = json.loads(trial_path.read_text(encoding="utf-8"))
document.update({"trial_status": self.status, "steps": self.completed_steps,
"trial_reason": self.entry["reason"]})
write_json(trial_path, document)
self.session.save()
except (OSError, ValueError):
self.status = "incomplete"
self.entry["status"] = "incomplete"
raise
write_json(trial_path, document)
self.session.save()
except (OSError, ValueError):
self.status = "incomplete"
self.entry["status"] = "incomplete"
raise
return self.status
def capture_summary(self) -> dict:
"""Live recording state for the operator label; no disk read."""
recorder = self.recorder
stats = recorder.stats() if recorder is not None else None
return {
"state": self.capture_state,
"reason": self.capture_reason,
"frames": int(getattr(stats, "frames", 0) or 0),
"video_frames": int(getattr(stats, "video_frames", 0) or 0),
"dropped_data": int(getattr(stats, "dropped_data", 0) or 0),
"dropped_video": int(getattr(stats, "dropped_video", 0) or 0),
"duration_s": float(getattr(stats, "duration_s", 0.0) or 0.0),
"directory": str(self.directory or ""),
}
@@ -29,9 +29,12 @@ from PySide6.QtWidgets import (
from ..sensor.uv_flat import site_at, uv_origin
from ..sensor.uv_outline import outline_snapshot
from ..summary import build_summary
from ..summary_export import publish_summary
from ..trial import ManualTrial, load_path
from .live_force_view import LiveForceView
from .multipoint_panel import MultipointPanel
from .review_job import ReviewJob
from .sensor_panel import FlatSurfaceView
#: Product models a session can be recorded under. The stored value is this id.
@@ -46,6 +49,9 @@ class CalibrationPanel(QWidget):
def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._summary_job: ReviewJob | None = None
self._summary_directory: Path | None = None
self._summary_ready_path: Path | None = None
self.source_panel = source_panel
self.trial = ManualTrial(source_panel.worker)
self._flat = None
@@ -305,6 +311,10 @@ class CalibrationPanel(QWidget):
def _confirm(self) -> None:
if self.multipoint.campaign.phase == "load":
if not self.multipoint.campaign.capture_ready:
self.result_label.setText(t("multipoint.capture_wait"))
self._sync()
return
self.multipoint.confirm_loaded()
return
try:
@@ -366,8 +376,46 @@ class CalibrationPanel(QWidget):
)
except (OSError, RuntimeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc))
else:
self.publish_summary_for(directory)
self._sync()
@property
def summary_publishing(self) -> bool:
return self._summary_job is not None
def publish_summary_for(self, directory) -> bool:
"""Build and publish the candidate summary in the background.
An export failure is reported and can be retried; it never changes the
acquisition result, and it never blocks the event loop.
"""
if self._summary_job is not None:
return False
path = Path(directory)
if not path.is_dir():
return False
self._summary_directory = path
self._summary_ready_path = None
def action():
summary = build_summary(path)
return publish_summary(path, summary)
self._summary_job = ReviewJob(action, self._summary_finished)
self._summary_job.start()
return True
def _summary_finished(self, value, error) -> None:
self._summary_job = None
if error is not None:
# The pointer still names the previous summary; the recording stays
# exactly as it was.
self.result_label.setText(t("summary.failed").format(detail=error))
return
self._summary_ready_path = Path(value)
self.result_label.setText(t("summary.published").format(path=value))
def _review(self) -> None:
session = self.trial.session
directory = QFileDialog.getExistingDirectory(
@@ -413,9 +461,11 @@ class CalibrationPanel(QWidget):
)
can_confirm = recording and self.trial.next_step is not None and not self.trial.holding
multipoint_load = self.multipoint.campaign.phase == "load"
self.confirm_button.setEnabled(can_confirm or multipoint_load)
multipoint_ready = self.multipoint.campaign.capture_ready
multipoint_confirm = multipoint_load and multipoint_ready
self.confirm_button.setEnabled(can_confirm or multipoint_confirm)
self.reference.setEnabled(
can_confirm or (multipoint_load and self.multipoint.source.currentData() != "wrench")
can_confirm or (multipoint_confirm and self.multipoint.source.currentData() != "wrench")
)
self.finish_button.setEnabled(recording and self.trial.next_step is None)
self.abort_button.setEnabled(recording)
@@ -433,7 +483,11 @@ class CalibrationPanel(QWidget):
else:
target_text = t("calibration.no_step")
if self.multipoint.campaign.phase == "load":
target_text = t("multipoint.right_confirm")
target_text = (
t("multipoint.right_confirm")
if self.multipoint.campaign.capture_ready
else t("multipoint.capture_wait")
)
self.target_label.setText(target_text)
self.progress_label.setText(
t("calibration.progress").format(
@@ -145,8 +145,11 @@ class MultipointPanel(QWidget):
self.progress = _WrappingLabel(page)
self.save_status = _WrappingLabel(page)
self.capture_status = _WrappingLabel(page)
self.capture_status.setObjectName("multipointCapture")
layout.addWidget(self.progress)
layout.addWidget(self.save_status)
layout.addWidget(self.capture_status)
self.pose_status = _WrappingLabel(page)
layout.addWidget(self.pose_status)
@@ -222,6 +225,7 @@ class MultipointPanel(QWidget):
self._timer.timeout.connect(self._poll)
self._timer.start()
self._listed: tuple = ()
self._summary_published_for = None
self.retranslate()
self.site_id.setText(self._next_site_id())
self.frame.setText("sensor")
@@ -239,6 +243,9 @@ class MultipointPanel(QWidget):
self._timer.stop()
if self.campaign.is_active:
self.campaign.stop("application closed")
# Bounded only at close time: the window cannot wait forever, and a
# writer that misses the deadline leaves an explicitly incomplete repeat.
self.campaign.flush_close()
def retranslate(self) -> None:
self.hint.setText(t("multipoint.hint"))
@@ -471,6 +478,7 @@ class MultipointPanel(QWidget):
def go():
if self.host.trial.status in {"recording", "closing"}:
raise RuntimeError(t("multipoint.finger_busy"))
self._summary_published_for = None
if not simulate:
worker = self.host.source_panel.worker
packet = worker.latest()
@@ -487,10 +495,17 @@ class MultipointPanel(QWidget):
simulate=simulate,
)
if not simulate:
self.campaign.attach_capture(
self.host.source_panel.worker,
fps=float(self.host.source_panel.mode.fps),
)
try:
self.campaign.attach_capture(
self.host.source_panel.worker,
fps=float(self.host.source_panel.mode.fps),
)
except (OSError, RuntimeError, ValueError) as exc:
# A repeat that cannot record its frames must not be offered
# for confirmation: stop it rather than collect unattached
# holds that would look complete.
self.campaign.stop(str(exc))
raise
self.message.setText(str(directory))
self._run(go)
@@ -537,6 +552,7 @@ class MultipointPanel(QWidget):
def _reacquire(self) -> None:
if self._selected:
self._summary_published_for = None
self._run(lambda: self.campaign.reacquire(self._selected))
def _liftoff(self) -> None:
@@ -553,6 +569,12 @@ class MultipointPanel(QWidget):
self._refresh()
def _poll(self) -> None:
try:
self.campaign.poll()
except (OSError, RuntimeError, ValueError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
if self.campaign.is_active:
self.campaign.stop(str(exc))
if self.campaign.phase == "hold" and self.campaign.remaining_s() <= 0:
try:
self.campaign.complete_hold()
@@ -560,8 +582,21 @@ class MultipointPanel(QWidget):
self.message.setText(t("calibration.error").format(detail=exc))
if self.campaign.is_active:
self.campaign.stop(str(exc))
self._publish_summary_when_finished()
self._refresh()
def _publish_summary_when_finished(self) -> None:
"""The candidate export starts when the campaign has closed its evidence."""
directory = self.campaign.directory
if directory is None or self.campaign.phase not in {"done", "aborted"}:
return
if self.campaign.capture_state == "closing":
return
if directory == self._summary_published_for:
return
if self.host.publish_summary_for(directory):
self._summary_published_for = directory
def _run(self, action) -> None:
try:
action()
@@ -604,6 +639,7 @@ class MultipointPanel(QWidget):
for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value))
active = self.campaign.is_active
busy = self.campaign.busy
wrench = self.source.currentData() == "wrench"
self.wrench_box.setVisible(wrench)
for widget in (
@@ -611,14 +647,14 @@ class MultipointPanel(QWidget):
self.down_button, self.simulate_button, self.start_button,
self.site_id, self.repeats, self.frame, self.max_force, self.min_force, self.source,
):
widget.setEnabled(not active)
widget.setEnabled(not busy)
for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation):
widget.setEnabled(self.campaign.phase == "load" and wrench)
self.liftoff_button.setEnabled(self.campaign.phase == "liftoff")
self.remeasure_button.setEnabled(self.campaign.can_remeasure_step())
self.stop_button.setEnabled(active)
self.reacquire_button.setEnabled(
not active and bool(self._selected) and self.campaign.directory is not None
not busy and bool(self._selected) and self.campaign.directory is not None
)
state = self.campaign.progress()
next_id = state["site_id"] if self.campaign.phase == "liftoff" else ""
@@ -669,8 +705,42 @@ class MultipointPanel(QWidget):
save = t("multipoint.save_done")
elif self.campaign.phase == "aborted":
save = t("multipoint.save_kept")
elif active:
elif active or self.campaign.closing:
save = t("multipoint.save_writing")
else:
save = t("multipoint.save_idle")
self.save_status.setText(t("multipoint.save").format(status=save, path=state["directory"] or "—"))
self.capture_status.setText(self._capture_text(state))
def _capture_text(self, state: dict) -> str:
"""Where the recording is, and why it is not complete, in one line."""
capture = state.get("capture") or {}
status = capture.get("state") or "idle"
key = {
"idle": "multipoint.capture_idle",
"recording": "multipoint.capture_recording",
"closing": "multipoint.capture_closing",
"complete": "multipoint.capture_complete",
"incomplete": "multipoint.capture_incomplete",
"not_recorded": "multipoint.capture_not_recorded",
}.get(status, "multipoint.capture_idle")
reason = capture.get("reason") or ""
reason_key = {
"source_restarted": "multipoint.capture_reason_restarted",
"zero_frames": "multipoint.capture_reason_zero_frames",
"write_error": "multipoint.capture_reason_write_error",
"video_missing": "multipoint.capture_reason_video_missing",
"video_undecodable": "multipoint.capture_reason_video_undecodable",
"dropped_data": "multipoint.capture_reason_dropped",
"dropped_video": "multipoint.capture_reason_dropped",
"segment_count": "multipoint.capture_reason_segments",
"closing_timeout": "multipoint.capture_reason_timeout",
"source_stopped": "multipoint.capture_reason_source_stopped",
"not_recorded": "multipoint.capture_reason_not_recorded",
}.get(reason)
return t("multipoint.capture").format(
status=t(key),
frames=int(capture.get("frames") or 0),
dropped=int(capture.get("dropped_data") or 0) + int(capture.get("dropped_video") or 0),
reason=t(reason_key) if reason_key else (reason or "—"),
)
@@ -134,6 +134,7 @@ class RecordOverviewDialog(QDialog):
self._dirty = False
self._selected_site_id = None
self.plan = None
self._review_path = None
self.rows = []
self.setWindowTitle(t("overview.title"))
self.resize(1240, 850)
@@ -161,8 +162,10 @@ class RecordOverviewDialog(QDialog):
self.tools.setText(t("overview.tools"))
self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = QMenu(self.tools)
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), ("load", self._load)):
action = menu.addAction(t("overview." + name))
for name, slot in (("video", self._video), ("summary", self._publish_summary),
("import_plan", self._import_plan),
("clear_plan", self._clear_plan), ("load", self._load)):
action = menu.addAction(t("overview." + name) if name != "summary" else t("summary.retry"))
action.setObjectName("overview_" + name)
action.triggered.connect(slot)
self.tools.setMenu(menu)
@@ -546,9 +549,68 @@ class RecordOverviewDialog(QDialog):
self._dirty = self._dirty or before != self.review["selections"]
self._refresh()
def _video(self):
"""Open the pictures of the selected point's current window."""
from .video_review_dialog import VideoReviewDialog
index = self.table.currentRow()
site_id = self.rows[index]["site_id"] if index >= 0 else None
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
if site is None or not site.get("windows"):
self.notice.setText(t("overview.guide_missing"))
return None
trial_filter = self.trials.currentData()
window = next(
(w for w in site["windows"] if trial_filter is None or w["trial_id"] == trial_filter),
None,
)
if window is None:
self.notice.setText(t("overview.guide_incomplete"))
return None
trial = next(
(item for item in self.review.get("trials") or []
if item["trial_id"] == window["trial_id"]), None,
)
if trial is None or trial.get("status") != "complete":
# An unclosed trial still has the writer's file open.
self.notice.setText(t("overview.guide_incomplete"))
return None
dialog = VideoReviewDialog(
self.directory, self.review,
trial_id=window["trial_id"], step_index=int(window["step_index"]), parent=self,
)
# Non-blocking modal: the operator keeps one window, but the caller can
# still be tested without entering a nested event loop.
dialog.setModal(True)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.show()
return dialog
def _save(self):
review, plan = self.review, self.plan
self._start_job("save", lambda: save_overview(review, plan))
directory = self.directory
def action():
path = save_overview(review, plan)
from ..summary_export import publish_reviewed_summary
summary_path = publish_reviewed_summary(directory, review, review_path=path)
return path, summary_path
self._start_job("save", action)
def _publish_summary(self):
"""Re-publish the summary for the loaded review (the retry path)."""
review = self.review
directory = self.directory
review_path = self._review_path
def action():
from ..summary_export import publish_reviewed_summary
return publish_reviewed_summary(directory, review, review_path=review_path)
self._start_job("summary", action)
@Slot(object, object)
def _job_finished(self, value, error):
@@ -560,11 +622,17 @@ class RecordOverviewDialog(QDialog):
elif self._job_kind == "save":
self._dirty = False
self._show_response()
self.message.setText(t("overview.saved"))
review_path, summary_path = value
self._review_path = review_path
self.message.setText(t("overview.saved_summary").format(
path=review_path, summary=summary_path))
self.message.show()
self.message.setToolTip(f"{review_path}\n{summary_path}")
elif self._job_kind == "summary":
self.message.setText(t("summary.done").format(path=value))
self.message.show()
self.message.setToolTip(str(value))
else:
self.review, self.plan = value
self.review, self.plan, self._review_path = value
self._dirty = False
self._refresh()
@@ -582,4 +650,10 @@ class RecordOverviewDialog(QDialog):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
if path:
directory = self.directory
self._start_job("load", lambda: load_overview(directory, Path(path)))
saved = Path(path)
def action():
review, plan = load_overview(directory, saved)
return review, plan, saved
self._start_job("load", action)
@@ -22,6 +22,16 @@ class ReviewJob(QRunnable):
value = self.action()
except Exception as exc:
# Worker failures must return to Qt so controls are always restored.
self.result.ready.emit(None, str(exc))
self._emit(None, str(exc))
else:
self.result.ready.emit(value, None)
self._emit(value, None)
def _emit(self, value, error) -> None:
"""Deliver the result, unless the window it belongs to has gone away."""
try:
self.result.ready.emit(value, error)
except RuntimeError:
# Closing the window deletes the receiver while a read is still in
# flight; there is nobody left to tell, and crashing the pool thread
# would be the only alternative.
return
@@ -92,12 +92,16 @@ class SessionReviewDialog(QDialog):
spin.setSuffix(" s")
self.verified = QCheckBox(t("review.confirm_stable"), self)
self.apply_button = QPushButton(t("review.apply_window"), self)
self.video_button = QPushButton(t("video.open"), self)
self.video_button.setObjectName("reviewVideo")
self.video_button.clicked.connect(self._video)
editor.addWidget(QLabel(t("review.start_offset"), self))
editor.addWidget(self.start_s)
editor.addWidget(QLabel(t("review.end_offset"), self))
editor.addWidget(self.end_s)
editor.addWidget(self.verified)
editor.addWidget(self.apply_button)
editor.addWidget(self.video_button)
hold_layout.addLayout(editor)
self.trace_chart = QChart()
self.trace_view = QChartView(self.trace_chart, self)
@@ -185,6 +189,7 @@ class SessionReviewDialog(QDialog):
window = self._current_window()
clear_chart(self.trace_chart)
self.apply_button.setEnabled(window is not None)
self.video_button.setEnabled(window is not None)
if window is None:
return
origin = window["hold_start_host_mono_ns"]
@@ -217,6 +222,30 @@ class SessionReviewDialog(QDialog):
self.trace_chart.legend().hide()
self.trace_chart.setTitle(t("review.trace_title"))
def _video(self):
"""Open the recorded pictures of the selected hold, read-only."""
window = self._current_window()
if window is None:
return None
trial = next(
(item for item in self.review.get("trials") or []
if item["trial_id"] == window["trial_id"]), None,
)
if trial is None or trial.get("status") != "complete":
# Only a closed trial has a finished container to decode.
self.message.setText(t("overview.guide_incomplete"))
return None
from .video_review_dialog import VideoReviewDialog
dialog = VideoReviewDialog(
self.directory, self.review,
trial_id=window["trial_id"], step_index=int(window["step_index"]), parent=self,
)
dialog.setModal(True)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.show()
return dialog
def _apply_window(self):
window = self._current_window()
if window is None:
@@ -279,7 +308,16 @@ class SessionReviewDialog(QDialog):
def _save(self) -> None:
review = self.review
self._start_job("save", lambda: save_review(review))
directory = self.directory
def action():
path = save_review(review)
from ..summary_export import publish_reviewed_summary
summary_path = publish_reviewed_summary(directory, review, review_path=path)
return path, summary_path
self._start_job("save", action)
def _start_job(self, kind, action):
if self._job is not None:
@@ -302,4 +340,6 @@ class SessionReviewDialog(QDialog):
self.review = value
self._show_site()
else:
self.message.setText(t("review.saved").format(path=value))
review_path, summary_path = value
self.message.setText(t("review.saved_summary").format(
path=review_path, summary=summary_path))
@@ -0,0 +1,359 @@
"""Play back one reviewed hold window from the recording that already exists.
The dialog decodes frames off the Qt thread and drops results that belong to a
request the operator has already moved past, so switching windows quickly cannot
show the previous hold's picture. Closing it releases the decoder.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
from PySide6.QtCore import QPointF, Qt, QTimer, Slot
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QVBoxLayout,
)
from ..video_review import Location, open_window_video
from .chart_helpers import clear_chart
from .review_job import ReviewJob
#: Playback advances one recorded frame per tick; the recorded timing is the
#: frame index, not the container's nominal rate.
PLAYBACK_INTERVAL_MS = 100
class VideoReviewDialog(QDialog):
"""The pictures of one hold, beside the curve the operator reviewed."""
def __init__(
self,
directory: Path,
review: dict,
*,
trial_id: str,
step_index: int,
parent=None,
) -> None:
super().__init__(parent)
self.setObjectName("videoReview")
self.resize(980, 720)
self.setWindowTitle(t("video.title"))
self.directory = Path(directory)
self.review = review
self.trial_id = trial_id
self.step_index = int(step_index)
self.window = self._window()
self.video = None
self.location: Location | None = None
self.index = 0
self._generation = 0
self._playing = False
self._job = None
self._reason = ""
self._closed = False
layout = QVBoxLayout(self)
self.header = QLabel(self)
self.header.setWordWrap(True)
layout.addWidget(self.header)
self.image = QLabel(self)
self.image.setMinimumSize(480, 320)
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image.setStyleSheet("#videoReview QLabel#videoFrame {background:#111;color:#ddd;}")
self.image.setObjectName("videoFrame")
layout.addWidget(self.image, 3)
self.curve = QChart()
self.curve_view = QChartView(self.curve, self)
self.curve_view.setMinimumHeight(180)
layout.addWidget(self.curve_view, 2)
controls = QHBoxLayout()
self.prev_button = QPushButton(t("video.previous"), self)
self.play_button = QPushButton(t("video.play"), self)
self.next_button = QPushButton(t("video.next"), self)
self.slider = QSlider(Qt.Orientation.Horizontal, self)
self.slider.setMinimum(0)
controls.addWidget(self.prev_button)
controls.addWidget(self.play_button)
controls.addWidget(self.next_button)
controls.addWidget(self.slider, 1)
layout.addLayout(controls)
self.position = QLabel(self)
self.position.setWordWrap(True)
layout.addWidget(self.position)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
self.prev_button.clicked.connect(lambda: self._step(-1))
self.next_button.clicked.connect(lambda: self._step(1))
self.play_button.clicked.connect(self._toggle_play)
self.slider.valueChanged.connect(self._slider_moved)
self.timer = QTimer(self)
self.timer.setInterval(PLAYBACK_INTERVAL_MS)
self.timer.timeout.connect(lambda: self._step(1, from_playback=True))
self._describe_window()
self._draw_curve()
self._open()
# ------------------------------------------------------------- set-up
def _window(self) -> dict:
for site in self.review.get("sites") or []:
for window in site.get("windows") or []:
if window["trial_id"] == self.trial_id and int(window["step_index"]) == self.step_index:
return window
return {}
def _describe_window(self) -> None:
window = self.window
if not window:
self.header.setText(t("video.reason_window_not_found"))
return
self.header.setText(t("video.header").format(
trial=self.trial_id,
step=window.get("step_index"),
branch=t("calibration." + str(window.get("branch") or "load")),
load=f"{float(window.get('reference_n') or 0.0):g}",
status=str(window.get("status") or ""),
samples=window.get("samples", 0),
))
def _open(self) -> None:
if not self.window:
self._disable(t("video.reason_window_not_found"))
return
self._set_busy(True)
review = self.review
trial_id, step_index = self.trial_id, self.step_index
review_job = ReviewJob(
lambda: open_window_video(review, trial_id=trial_id, step_index=step_index),
self._opened,
)
self._job = review_job
review_job.start()
@Slot(object, object)
def _opened(self, value, error) -> None:
self._job = None
if self._closed:
if error is None and value is not None:
value.close()
return
self._set_busy(False)
if error is not None:
self._disable(self._reason_text(error))
return
self.video = value
self.location = value.location
self.slider.setMaximum(max(0, value.frame_count - 1))
self.slider.setValue(0)
self.prev_button.setEnabled(True)
self.next_button.setEnabled(True)
self.play_button.setEnabled(value.frame_count > 0)
self._request(0)
def _reason_text(self, error: str) -> str:
key = {
"no_frame_source": "video.reason_no_frame_source",
"no_video_frame": "video.reason_no_video_frame",
"trial_not_found": "video.reason_trial_not_found",
"window_not_found": "video.reason_window_not_found",
"hold_not_found": "video.reason_hold_not_found",
"hold_not_complete": "video.reason_hold_not_complete",
"video_undecodable": "video.reason_video_undecodable",
}.get(str(error))
return t(key) if key else t("video.reason_missing").format(detail=error)
def _disable(self, message: str) -> None:
self._reason = message
self.play_button.setEnabled(False)
self.prev_button.setEnabled(False)
self.next_button.setEnabled(False)
self.slider.setEnabled(False)
self.image.setText(message)
self.position.setText(message)
self.message.setText(message)
def _set_busy(self, busy: bool) -> None:
for widget in (self.play_button, self.prev_button, self.next_button, self.slider):
widget.setEnabled(not busy)
if busy:
self.message.setText(t("review.checking"))
# ------------------------------------------------------------ playback
def _request(self, index: int) -> None:
video = self.video
if video is None or video.frame_count == 0 or self._closed:
return
index = max(0, min(int(index), video.frame_count - 1))
self.index = index
self._generation += 1
generation = self._generation
self._show_position(index)
job = ReviewJob(
lambda: (generation, index, video.frame(index)),
self._frame_ready,
)
self._job = job
job.start()
@Slot(object, object)
def _frame_ready(self, value, error) -> None:
self._job = None
if self._closed:
# The decoder was released with the dialog; a late picture must not
# reopen it or paint over the closed window.
return
if error is not None:
self.message.setText(t("video.reason_decode"))
return
generation, index, image = value
if generation != self._generation:
return # the operator moved on; this picture belongs to an old request
if image is None:
self.image.setText(t("video.frame_missing"))
self.message.setText(t("video.frame_missing"))
return
self.image.setPixmap(self._pixmap(image))
self.message.clear()
self._draw_cursor(index)
def _pixmap(self, image: np.ndarray) -> QPixmap:
rgb = np.ascontiguousarray(np.asarray(image)[:, :, ::-1])
height, width = rgb.shape[:2]
qimage = QImage(rgb.data, width, height, 3 * width, QImage.Format.Format_RGB888)
return QPixmap.fromImage(qimage.copy()).scaled(
self.image.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
def _slider_moved(self, value: int) -> None:
if self.video is None:
return
if value != self.index:
self._request(value)
def _step(self, delta: int, *, from_playback: bool = False) -> None:
if self.video is None or self.video.frame_count == 0:
return
target = self.index + delta
if target >= self.video.frame_count:
self._stop_playback()
return
target = max(0, target)
self.slider.blockSignals(True)
self.slider.setValue(target)
self.slider.blockSignals(False)
self._request(target)
if not from_playback:
self._stop_playback()
def _toggle_play(self) -> None:
if self._playing:
self._stop_playback()
else:
self._playing = True
self.play_button.setText(t("video.pause"))
self.timer.start()
def _stop_playback(self) -> None:
self._playing = False
self.timer.stop()
self.play_button.setText(t("video.play"))
def _show_position(self, index: int) -> None:
location = self.location
window = self.window
if location is None:
return
frame = self.video.frames[index] if self.video is not None else None
self.position.setText(t("video.position").format(
index=index + 1,
total=self.video.frame_count if self.video is not None else 0,
frame=frame.frame_id if frame is not None else "—",
segment=frame.segment if frame is not None else "—",
segment_frame=frame.segment_frame if frame is not None else "—",
basis=str(window.get("time_basis") or t("video.basis_unknown")),
))
# -------------------------------------------------------------- charts
def _draw_curve(self) -> None:
clear_chart(self.curve)
window = self.window
if not window:
return
origin = int(window["hold_start_host_mono_ns"])
trace = QScatterSeries()
trace.setMarkerSize(3)
trace.setName(t("review.valid_samples"))
trace.replace([
QPointF((stamp - origin) / 1e9, value)
for stamp, _frame, value in window.get("trace") or []
])
self.curve.addSeries(trace)
self.curve_line = QLineSeries()
self.curve_line.setName(t("video.cursor"))
self.curve.addSeries(self.curve_line)
reference = QLineSeries()
reference.setName(t("video.reference"))
reference.append(0.0, float(window.get("reference_n") or 0.0))
reference.append(max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001),
float(window.get("reference_n") or 0.0))
self.curve.addSeries(reference)
axis_x = QValueAxis()
axis_x.setTitleText(t("review.hold_time_axis"))
axis_x.setRange(0, max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001))
axis_y = QValueAxis()
axis_y.setTitleText(t("review.raw_axis"))
values = [value for _stamp, _frame, value in window.get("trace") or []]
values.append(float(window.get("reference_n") or 0.0))
low, high = (min(values), max(values)) if values else (0.0, 1.0)
margin = max((high - low) * 0.1, 0.1)
axis_y.setRange(low - margin, high + margin)
self.curve.addAxis(axis_x, Qt.AlignmentFlag.AlignBottom)
self.curve.addAxis(axis_y, Qt.AlignmentFlag.AlignLeft)
for series in self.curve.series():
series.attachAxis(axis_x)
series.attachAxis(axis_y)
self.curve.legend().hide()
self.curve.setTitle(t("video.curve_title"))
self._cursor_axis_range = (0.0, axis_x.max())
def _draw_cursor(self, index: int) -> None:
if self.video is None or not hasattr(self, "curve_line"):
return
window = self.window
frame = self.video.frames[index]
origin = int(window["hold_start_host_mono_ns"])
x = (frame.t_host_mono_ns - origin) / 1e9
axis = self.curve.axes(Qt.Orientation.Vertical)
low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0)
self.curve_line.replace([QPointF(x, low), QPointF(x, high)])
# ------------------------------------------------------------- teardown
def closeEvent(self, event) -> None:
self._closed = True
self._stop_playback()
if self.video is not None:
self.video.close()
self.video = None
super().closeEvent(event)
def done(self, result: int) -> None:
self._closed = True
self._stop_playback()
if self.video is not None:
self.video.close()
self.video = None
super().done(result)
@@ -0,0 +1,263 @@
"""Find a recorded hold's picture in the video that already exists.
The operator picks a reviewed window and expects the frames of *that* hold. The
position is decided by frame identity — acquisition time plus the recorded
segment and position — never by converting the player's second counter, and
never by assuming the container runs at the acquisition rate.
Read-only: no calibration is recomputed, no frames are invented and a missing
segment stays a reason. The decoder is released by :meth:`WindowVideo.close`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from orisys_app_shared.record import RecordingReader
from .frames_index import (
FrameRow,
read_frame_index,
reference_holds,
resolve_capture_directory,
unique_frames,
)
#: Why a window has no picture. Tokens, localised by the dialog.
NO_TRIAL = "trial_not_found"
NO_WINDOW = "window_not_found"
NO_FRAME_SOURCE = "no_frame_source"
NO_VIDEO_FRAME = "no_video_frame"
HOLD_NOT_FOUND = "hold_not_found"
HOLD_NOT_COMPLETE = "hold_not_complete"
DECODE_FAILED = "video_undecodable"
@dataclass(frozen=True)
class Location:
"""One identified picture position, in recording coordinates."""
trial_id: str
step_index: int
hold_id: int | None
frame_id: int
t_rel_s: float
t_host_mono_ns: int
segment: int
segment_frame: int
capture_directory: str
def as_dict(self) -> dict:
return {
"trial_id": self.trial_id,
"step_index": self.step_index,
"hold_id": self.hold_id,
"frame_id": self.frame_id,
"t_rel_s": self.t_rel_s,
"t_host_mono_ns": self.t_host_mono_ns,
"segment": self.segment,
"segment_frame": self.segment_frame,
"capture_directory": self.capture_directory,
}
def _window(review: dict, *, trial_id: str, step_index: int) -> dict | None:
for site in review.get("sites") or []:
for window in site.get("windows") or []:
if window["trial_id"] == trial_id and int(window["step_index"]) == int(step_index):
return window
return None
def _trial(review: dict, trial_id: str) -> dict | None:
for trial in review.get("trials") or []:
if trial["trial_id"] == trial_id:
return trial
return None
def in_window_frames(
review: dict, *, trial_id: str, step_index: int
) -> list[FrameRow] | str:
"""The in-video frame rows inside one hold, in time order. A token on failure."""
root = Path(review["source_directory"])
trial = _trial(review, trial_id)
if trial is None:
return NO_TRIAL
window = _window(review, trial_id=trial_id, step_index=step_index)
if window is None:
return NO_WINDOW
directory = resolve_capture_directory(
root,
trial_directory=str(trial.get("directory") or ""),
frame_source=str(window.get("frame_source") or ""),
)
if directory is None:
return NO_FRAME_SOURCE
start = int(window["hold_start_host_mono_ns"])
end = int(window["hold_end_host_mono_ns"])
unique, _duplicated = unique_frames(read_frame_index(directory))
frames = [
frame for frame in unique.values()
if frame.in_video is True
and frame.segment > 0
and frame.segment_frame > 0
and frame.t_rel_s is not None
and frame.t_host_mono_ns is not None
and start <= frame.t_host_mono_ns < end
]
for frame in frames:
path = directory / f"raw_{frame.segment:04d}.mp4"
if not path.is_file() or path.stat().st_size == 0:
return NO_FRAME_SOURCE
return sorted(frames, key=lambda frame: (frame.t_host_mono_ns, frame.segment, frame.segment_frame))
def _hold(root: Path, trial: dict, hold_id: int) -> dict | None:
for row in reference_holds(root / str(trial.get("directory") or "")):
if row["hold_id"] == int(hold_id):
return row
return None
def locate_window(
review: dict, *, trial_id: str, step_index: int, hold_id: int | None = None
) -> Location | dict:
"""The first valid in-video frame of the hold, or a reason it is missing.
`hold_id` names one actual attempt from `reference.csv`; a discarded
attempt is refused instead of quietly showing the completed one.
"""
trial = _trial(review, trial_id)
if trial is None:
return {"reason": NO_TRIAL}
if hold_id is not None:
hold = _hold(Path(review["source_directory"]), trial, hold_id)
if hold is None:
return {"reason": HOLD_NOT_FOUND}
if not hold["complete"]:
return {"reason": HOLD_NOT_COMPLETE}
if hold["step_index"] != int(step_index):
return {"reason": NO_WINDOW}
window = _window(review, trial_id=trial_id, step_index=step_index)
if window is None:
return {"reason": NO_WINDOW}
frames = in_window_frames(review, trial_id=trial_id, step_index=step_index)
if isinstance(frames, str):
return {"reason": frames}
if not frames:
return {"reason": NO_VIDEO_FRAME}
# Only a frame that also produced a valid sample is evidence of the hold.
valid_ids = {
int(frame_id) for _stamp, frame_id, _value in window.get("trace") or []
}
root = Path(review["source_directory"])
trial = _trial(review, trial_id)
for frame in frames:
if valid_ids and frame.frame_id not in valid_ids:
continue
directory = resolve_capture_directory(
root,
trial_directory=str(trial.get("directory") or ""),
frame_source=str(window.get("frame_source") or ""),
)
return Location(
trial_id=trial_id,
step_index=int(step_index),
hold_id=None if hold_id is None else int(hold_id),
frame_id=frame.frame_id,
t_rel_s=float(frame.t_rel_s),
t_host_mono_ns=int(frame.t_host_mono_ns),
segment=frame.segment,
segment_frame=frame.segment_frame,
capture_directory=str(directory.relative_to(root)) if directory else "",
)
return {"reason": NO_VIDEO_FRAME}
def _decode_direct(directory: Path, *, segment: int, segment_frame: int):
"""Fallback decode by segment position, for an index that lies about time."""
import cv2
path = directory / f"raw_{segment:04d}.mp4"
if not path.is_file():
return None
capture = cv2.VideoCapture(str(path))
try:
if not capture.isOpened():
return None
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
ok, image = capture.read()
return image if ok else None
finally:
capture.release()
@dataclass
class WindowVideo:
"""Decode the frames of one hold, stepping while the operator plays or pauses."""
location: Location
reader: RecordingReader
frames: list[FrameRow]
#: Index of the frame currently decoded, or -1 when the decoder was reset.
position: int = -1
_images: dict[int, np.ndarray] = field(default_factory=dict)
@property
def frame_count(self) -> int:
return len(self.frames)
def frame(self, index: int) -> np.ndarray | None:
"""Image for ``frames[index]``, or ``None`` when that frame has no picture."""
index = max(0, min(int(index), self.frame_count - 1))
if index in self._images:
return self._images[index]
row = self.frames[index]
try:
image, served = self.reader.video_frame_at(row.t_rel_s)
except Exception: # noqa: BLE001 - a broken decode must not crash playback
image, served = None, None
if image is None or served is None or served.frame_id != row.frame_id:
# The time lookup is "at or before": a repeated timestamp can answer
# with a neighbour. Seek the recorded position instead of showing it.
image = _decode_direct(
self.reader.directory, segment=row.segment, segment_frame=row.segment_frame
)
if image is None:
self.position = -1
return None
self.position = index
self._images[index] = image
return image
def release(self) -> None:
self.reader.close()
self._images.clear()
self.position = -1
def close(self) -> None:
self.release()
def __enter__(self) -> "WindowVideo":
return self
def __exit__(self, *exc_info) -> None:
self.release()
def open_window_video(review: dict, *, trial_id: str, step_index: int) -> WindowVideo:
"""Open the decoder for one hold. Raises ``ValueError`` carrying the reason token."""
location = locate_window(review, trial_id=trial_id, step_index=step_index)
if not isinstance(location, Location):
raise ValueError(location["reason"])
frames = in_window_frames(review, trial_id=trial_id, step_index=step_index)
if isinstance(frames, str):
raise ValueError(frames)
root = Path(review["source_directory"])
directory = (root / location.capture_directory).resolve()
reader = RecordingReader(directory)
return WindowVideo(location=location, reader=reader, frames=frames)