Fix repeat ownership and close out capture evidence
Start each repeat after its own manifest entry, refuse packets from a restarted or stopped camera, keep a simulated repeat from reading as recorded evidence, and decide completeness from the writer report. Add the operator summary (four files plus an atomic pointer) and read-only video review of a hold window. Verified with the full Autocal suite (567 passed), the shared recorder/playback files (57 passed), the lint gate and the documentation gate; the two pre-existing panel_check layout failures also fail on a clean HEAD worktree.
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user