from __future__ import annotations import csv from dataclasses import dataclass, field from pathlib import Path IDENTITY_KEYS = ("mbt_source_id", "mbt_source_frame", "mbt_drop_frame") @dataclass(frozen=True) class FrameIdentity: output_frame: int source_id: int | None source_frame: int | None drop_frame: bool = False matrix: int | None = None @dataclass(frozen=True) class IdentityValidationResult: accepted: bool frame_count: int kept_frame_count: int dropped_frame_count: int source_ids: list[int] issues: list[str] = field(default_factory=list) def validate_frame_identities( frames: list[FrameIdentity], *, expected_source_ids: set[int] | None = None, ) -> IdentityValidationResult: issues: list[str] = [] source_ids: set[int] = set() seen_refs: set[tuple[int, int]] = set() seen_output_frames: set[int] = set() last_ref: tuple[int, int] | None = None kept_count = 0 dropped_count = 0 for frame in frames: if frame.output_frame < 0: issues.append(f"output frame {frame.output_frame} is negative") if frame.output_frame in seen_output_frames: issues.append(f"output frame {frame.output_frame} appears more than once") seen_output_frames.add(frame.output_frame) if frames: max_output_frame = max(frame.output_frame for frame in frames) missing_output_frames = sorted( set(range(max_output_frame + 1)) - seen_output_frames ) if missing_output_frames: issues.append( "missing output frame row(s): " + _format_int_list(missing_output_frames) ) for frame in sorted(frames, key=lambda item: item.output_frame): if frame.source_id is None: issues.append(f"output frame {frame.output_frame} is missing mbt_source_id") continue if frame.source_frame is None: issues.append(f"output frame {frame.output_frame} is missing mbt_source_frame") continue if frame.source_frame < 0: issues.append(f"output frame {frame.output_frame} has a negative source frame") continue if expected_source_ids is not None and frame.source_id not in expected_source_ids: issues.append( f"output frame {frame.output_frame} references unknown source id " f"{frame.source_id}" ) continue source_ids.add(frame.source_id) ref = (frame.source_id, frame.source_frame) if ref in seen_refs: issues.append( f"output frame {frame.output_frame} duplicates source " f"{frame.source_id}:{frame.source_frame}" ) seen_refs.add(ref) if last_ref is not None and ref < last_ref: issues.append( f"output frame {frame.output_frame} reorders source frames " f"from {last_ref[0]}:{last_ref[1]} to {ref[0]}:{ref[1]}" ) last_ref = ref if frame.drop_frame: dropped_count += 1 continue kept_count += 1 return IdentityValidationResult( accepted=not issues, frame_count=len(frames), kept_frame_count=kept_count, dropped_frame_count=dropped_count, source_ids=sorted(source_ids), issues=issues, ) def read_frame_identity_csv(path: Path) -> list[FrameIdentity]: frames: list[FrameIdentity] = [] with path.open(newline="", encoding="utf-8") as handle: reader = csv.reader(handle) for line_number, row in enumerate(reader, start=1): if line_number == 1 and row in ( ["output_frame", "source_id", "source_frame", "drop_frame"], ["output_frame", "source_id", "source_frame", "drop_frame", "matrix"], ): continue if not row or all(not value.strip() for value in row): continue if len(row) not in {4, 5}: raise RuntimeError( f"{path}:{line_number}: expected 4 or 5 CSV columns, got {len(row)}" ) try: output_frame = int(row[0]) source_id = _optional_int(row[1]) source_frame = _optional_int(row[2]) drop_frame = bool(int(row[3] or "0")) matrix = _optional_int(row[4]) if len(row) == 5 else None except ValueError as exc: raise RuntimeError( f"{path}:{line_number}: invalid frame identity row: {row}" ) from exc frames.append( FrameIdentity( output_frame=output_frame, source_id=source_id, source_frame=source_frame, drop_frame=drop_frame, matrix=matrix, ) ) return frames def _optional_int(value: str) -> int | None: value = value.strip() return int(value) if value else None def _format_int_list(values: list[int]) -> str: if len(values) <= 10: return ", ".join(str(value) for value in values) head = ", ".join(str(value) for value in values[:10]) return f"{head}, ... ({len(values)} total)"