avialsync 0.1.0b5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. avialsync/__init__.py +3 -0
  2. avialsync/__main__.py +117 -0
  3. avialsync/core/__init__.py +1 -0
  4. avialsync/core/cache.py +183 -0
  5. avialsync/core/channel_reader.py +165 -0
  6. avialsync/core/errors.py +73 -0
  7. avialsync/core/inspection.py +146 -0
  8. avialsync/core/pyramid.py +508 -0
  9. avialsync/core/registry.py +150 -0
  10. avialsync/core/session.py +257 -0
  11. avialsync/core/source.py +173 -0
  12. avialsync/core/sync.py +374 -0
  13. avialsync/core/timeline.py +299 -0
  14. avialsync/demo.py +531 -0
  15. avialsync/engine/__init__.py +1 -0
  16. avialsync/engine/drop_worker.py +279 -0
  17. avialsync/engine/export.py +227 -0
  18. avialsync/engine/export_worker.py +200 -0
  19. avialsync/engine/importer.py +312 -0
  20. avialsync/engine/player.py +433 -0
  21. avialsync/engine/proxy.py +106 -0
  22. avialsync/engine/seeker.py +40 -0
  23. avialsync/engine/session_worker.py +70 -0
  24. avialsync/engine/sync_worker.py +96 -0
  25. avialsync/engine/video_worker.py +65 -0
  26. avialsync/loaders/__init__.py +1 -0
  27. avialsync/loaders/aol_eks_loader.py +259 -0
  28. avialsync/loaders/aol_encoder_loader.py +287 -0
  29. avialsync/loaders/aol_session_loader.py +356 -0
  30. avialsync/loaders/csv_loader.py +339 -0
  31. avialsync/loaders/neo_loader.py +328 -0
  32. avialsync/loaders/tracking_loader.py +152 -0
  33. avialsync/loaders/video_standard.py +276 -0
  34. avialsync/resources/__init__.py +1 -0
  35. avialsync/resources/avialsync.png +0 -0
  36. avialsync/runtime.py +161 -0
  37. avialsync/ui/__init__.py +1 -0
  38. avialsync/ui/annotations.py +211 -0
  39. avialsync/ui/batch_import_dialog.py +130 -0
  40. avialsync/ui/diagnostics.py +213 -0
  41. avialsync/ui/import_report.py +105 -0
  42. avialsync/ui/import_wizard.py +397 -0
  43. avialsync/ui/job_manager.py +297 -0
  44. avialsync/ui/main_window.py +2884 -0
  45. avialsync/ui/offsets_panel.py +1 -0
  46. avialsync/ui/pane_proportions.py +199 -0
  47. avialsync/ui/plot_header.py +69 -0
  48. avialsync/ui/plot_interactions.py +142 -0
  49. avialsync/ui/plot_overlays.py +177 -0
  50. avialsync/ui/plot_pane.py +812 -0
  51. avialsync/ui/plot_row.py +274 -0
  52. avialsync/ui/plot_sweep.py +342 -0
  53. avialsync/ui/readout_panel.py +335 -0
  54. avialsync/ui/recent_files.py +40 -0
  55. avialsync/ui/relink_dialog.py +118 -0
  56. avialsync/ui/shortcuts_dialog.py +92 -0
  57. avialsync/ui/sidebar.py +535 -0
  58. avialsync/ui/source_properties.py +237 -0
  59. avialsync/ui/sync_wizard.py +219 -0
  60. avialsync/ui/theme.py +365 -0
  61. avialsync/ui/time_format.py +43 -0
  62. avialsync/ui/tracking_3d_pane.py +648 -0
  63. avialsync/ui/tracking_colors.py +111 -0
  64. avialsync/ui/transport.py +948 -0
  65. avialsync/ui/ui_heartbeat.py +83 -0
  66. avialsync/ui/video_grid.py +353 -0
  67. avialsync/ui/video_overlay.py +272 -0
  68. avialsync/ui/video_pane.py +608 -0
  69. avialsync/ui/video_timing.py +282 -0
  70. avialsync-0.1.0b5.dist-info/METADATA +236 -0
  71. avialsync-0.1.0b5.dist-info/RECORD +74 -0
  72. avialsync-0.1.0b5.dist-info/WHEEL +4 -0
  73. avialsync-0.1.0b5.dist-info/entry_points.txt +10 -0
  74. avialsync-0.1.0b5.dist-info/licenses/LICENSE +3 -0
avialsync/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AvialSync root module."""
2
+
3
+ __version__ = "0.1.0b5"
avialsync/__main__.py ADDED
@@ -0,0 +1,117 @@
1
+ """CLI entry point."""
2
+
3
+ import argparse
4
+ import locale
5
+ import os
6
+ import sys
7
+ import time
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from avialsync.runtime import configure_media_runtime
12
+
13
+
14
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
15
+ """Parse the supported AvialSync command-line arguments."""
16
+ parser = argparse.ArgumentParser(prog="avialsync")
17
+ parser.add_argument(
18
+ "command",
19
+ nargs="?",
20
+ choices=("demo", "open"),
21
+ help="demo: generate and load the inspection demo. open: load PATH.",
22
+ )
23
+ parser.add_argument(
24
+ "path",
25
+ nargs="?",
26
+ type=Path,
27
+ help="Session file (.avv) or a folder of recordings, for 'open'.",
28
+ )
29
+ parser.add_argument("--smoke-test", action="store_true", help=argparse.SUPPRESS)
30
+ args = parser.parse_args(argv)
31
+ if args.command == "open":
32
+ if args.path is None:
33
+ parser.error("open needs a path: avialsync open <session.avv|folder>")
34
+ if not args.path.exists():
35
+ # Refuse before Qt starts: a missing path is a typo, and reporting
36
+ # it in a dialog behind a window that has already opened is worse
37
+ # than reporting it on the terminal that issued the command.
38
+ parser.error(f"no such file or folder: {args.path}")
39
+ elif args.path is not None:
40
+ parser.error(f"'{args.command}' takes no path argument")
41
+ return args
42
+
43
+
44
+ def main() -> None:
45
+ args = _parse_args(sys.argv[1:])
46
+ from PySide6.QtCore import QTimer
47
+ from PySide6.QtGui import QIcon
48
+ from PySide6.QtWidgets import QApplication
49
+
50
+ from avialsync.demo import DEMO_CHANNEL_COUNT, DEMO_VIDEO_COUNT, DemoLaunch
51
+ from avialsync.ui.main_window import MainWindow
52
+ from avialsync.ui.theme import load_saved_font_size, load_saved_theme
53
+
54
+ configure_media_runtime()
55
+ app = QApplication(sys.argv)
56
+ app_icon = QIcon(str(files("avialsync.resources").joinpath("avialsync.png")))
57
+ app.setWindowIcon(app_icon)
58
+
59
+ # Prevent Qt from stomping LC_NUMERIC (breaks libmpv float parsing)
60
+ locale.setlocale(locale.LC_NUMERIC, "C")
61
+
62
+ load_saved_theme(app)
63
+ load_saved_font_size(app)
64
+
65
+ win = MainWindow()
66
+ win.setWindowIcon(app_icon)
67
+ win.show()
68
+ demo_launch = DemoLaunch(win) if args.command == "demo" else None
69
+ if demo_launch is not None:
70
+ QTimer.singleShot(0, demo_launch.start)
71
+ elif args.command == "open":
72
+ # Deferred so the window is mapped before scanning reports progress.
73
+ QTimer.singleShot(0, lambda: win.open_path(args.path))
74
+
75
+ if args.smoke_test and demo_launch is None:
76
+ QTimer.singleShot(250, win.close)
77
+ elif args.smoke_test:
78
+ smoke_started = time.monotonic()
79
+ # The harness sets this below its own timeout so this deadline fires
80
+ # first and reports what the demo was still waiting for. A slow runner
81
+ # needs a longer budget than a workstation, so it is not a constant.
82
+ smoke_deadline = float(os.environ.get("AVIALSYNC_SMOKE_DEADLINE_S", "110"))
83
+
84
+ def poll_demo_ready() -> None:
85
+ panes = win.video_grid.panes
86
+ videos_ready = (
87
+ len(panes) == DEMO_VIDEO_COUNT
88
+ and all(pane._media_loaded for pane in panes)
89
+ and not win._pending_video_loads
90
+ and not win._video_load_jobs
91
+ and win._video_pane_initializing is None
92
+ )
93
+ data_ready = (
94
+ len(win.plot_pane.channels) == DEMO_CHANNEL_COUNT
95
+ and not win._pending_imports
96
+ and win._import_thread is None
97
+ )
98
+ if videos_ready and data_ready:
99
+ win.close()
100
+ return
101
+ if time.monotonic() - smoke_started >= smoke_deadline:
102
+ print(
103
+ "Demo smoke timed out: "
104
+ f"videos={len(panes)}/{DEMO_VIDEO_COUNT}, "
105
+ f"channels={len(win.plot_pane.channels)}/{DEMO_CHANNEL_COUNT}",
106
+ file=sys.stderr,
107
+ )
108
+ app.exit(2)
109
+ return
110
+ QTimer.singleShot(50, poll_demo_ready)
111
+
112
+ QTimer.singleShot(50, poll_demo_ready)
113
+ sys.exit(app.exec())
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1 @@
1
+ """Core module."""
@@ -0,0 +1,183 @@
1
+ """Cache management for sidecar files."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import uuid
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import xxhash
13
+
14
+ from avialsync.core.errors import CacheError
15
+
16
+
17
+ class CacheManager:
18
+ """Manages .avialcache sidecar directories with atomic writes and hardened keys."""
19
+
20
+ def __init__(
21
+ self,
22
+ loader_version: int = 1,
23
+ cache_config: Mapping[str, Any] | None = None,
24
+ ):
25
+ self.loader_version = loader_version
26
+ self._cache_config = dict(cache_config or {})
27
+
28
+ def _hash_file_edges(self, path: Path) -> str:
29
+ """Hash the first and last 64KB of the file."""
30
+ if path.is_dir():
31
+ # For directories, edge hashing is not applicable. The cache key
32
+ # will rely on the directory's mtime and size in generate_key.
33
+ return xxhash.xxh64(str(path.absolute()).encode("utf-8")).hexdigest()
34
+
35
+ size = path.stat().st_size
36
+ chunk_size = 64 * 1024
37
+
38
+ h = xxhash.xxh64()
39
+ with open(path, "rb") as f:
40
+ # First 64KB
41
+ chunk = f.read(chunk_size)
42
+ h.update(chunk)
43
+
44
+ # Last 64KB
45
+ if size > chunk_size:
46
+ seek_pos = max(chunk_size, size - chunk_size)
47
+ f.seek(seek_pos)
48
+ chunk = f.read(chunk_size)
49
+ h.update(chunk)
50
+
51
+ return h.hexdigest()
52
+
53
+ def generate_key(self, path: Path) -> str:
54
+ """Generate cache invalidation key per D-008."""
55
+ if not path.exists():
56
+ raise FileNotFoundError(f"Source file not found: {path}")
57
+
58
+ stat = path.stat()
59
+ edge_hash = self._hash_file_edges(path)
60
+
61
+ key_data = {
62
+ "path": str(path.absolute()),
63
+ "size": stat.st_size,
64
+ "mtime": stat.st_mtime,
65
+ "loader_version": self.loader_version,
66
+ "cache_config": self._cache_config,
67
+ "hash": edge_hash,
68
+ }
69
+
70
+ return json.dumps(key_data, sort_keys=True)
71
+
72
+ def get_cache_dir(self, source_path: Path) -> Path:
73
+ """Return the path to the sidecar cache directory."""
74
+ return source_path.with_name(source_path.name + ".avialcache")
75
+
76
+ def is_cache_valid(self, source_path: Path) -> bool:
77
+ """Check if the cache directory exists and the key matches."""
78
+ cache_dir = self.get_cache_dir(source_path)
79
+ self._recover_interrupted_swap(cache_dir)
80
+ meta_path = cache_dir / "meta.json"
81
+
82
+ if not cache_dir.exists() or not meta_path.exists():
83
+ return False
84
+
85
+ try:
86
+ with open(meta_path) as f:
87
+ cached_key = f.read()
88
+
89
+ current_key = self.generate_key(source_path)
90
+ return cached_key == current_key
91
+ except Exception:
92
+ return False
93
+
94
+ def get_temp_cache_dir(self, source_path: Path) -> Path:
95
+ """Get a temporary directory for writing cache. Ensure atomic swap later."""
96
+ parent = source_path.parent
97
+ temp_dir = tempfile.mkdtemp(prefix=".tmp_avialcache_", dir=parent)
98
+ return Path(temp_dir)
99
+
100
+ def commit_cache(self, source_path: Path, temp_dir: Path) -> None:
101
+ """Commit a replacement without discarding the last valid sidecar first."""
102
+ cache_dir = self.get_cache_dir(source_path)
103
+
104
+ # Write metadata key
105
+ meta_path = temp_dir / "meta.json"
106
+ with open(meta_path, "w") as f:
107
+ f.write(self.generate_key(source_path))
108
+
109
+ backup_dir: Path | None = None
110
+ try:
111
+ if cache_dir.exists():
112
+ backup_dir = cache_dir.with_name(f".{cache_dir.name}.backup-{uuid.uuid4().hex}")
113
+ os.rename(cache_dir, backup_dir)
114
+ os.rename(temp_dir, cache_dir)
115
+ except OSError as rename_error:
116
+ if backup_dir is not None and backup_dir.exists() and not cache_dir.exists():
117
+ try:
118
+ os.rename(backup_dir, cache_dir)
119
+ except OSError:
120
+ pass
121
+ # Renaming a *directory* can fail even when its files are writable:
122
+ # on Windows a sync client (OneDrive), search indexer or antivirus
123
+ # commonly holds a handle on the directory itself, which is normal
124
+ # under a synced Documents folder. Fall back to a file-level swap
125
+ # that never renames a directory.
126
+ try:
127
+ self._commit_in_place(source_path, cache_dir, temp_dir)
128
+ except OSError as swap_error:
129
+ shutil.rmtree(temp_dir, ignore_errors=True)
130
+ raise CacheError(
131
+ f"Failed to commit cache: {rename_error}; "
132
+ f"in-place fallback also failed: {swap_error}"
133
+ ) from swap_error
134
+ return
135
+ if backup_dir is not None:
136
+ try:
137
+ shutil.rmtree(backup_dir)
138
+ except OSError as error:
139
+ raise CacheError(f"Committed cache but could not remove backup: {error}") from error
140
+
141
+ def _commit_in_place(self, source_path: Path, cache_dir: Path, temp_dir: Path) -> None:
142
+ """Replace a sidecar's contents without renaming the directory.
143
+
144
+ Individual files can be replaced even while the enclosing directory is
145
+ held open. ``meta.json`` is removed first and written last, so an
146
+ interruption leaves the sidecar *invalid* (and therefore rebuilt) rather
147
+ than a mix of old and new arrays.
148
+ """
149
+ cache_dir.mkdir(parents=True, exist_ok=True)
150
+
151
+ meta_path = cache_dir / "meta.json"
152
+ if meta_path.exists():
153
+ meta_path.unlink()
154
+
155
+ staged = {item.name for item in temp_dir.iterdir() if item.is_file()}
156
+ for item in temp_dir.iterdir():
157
+ if item.is_file() and item.name != "meta.json":
158
+ os.replace(item, cache_dir / item.name)
159
+
160
+ # Drop arrays that the new build no longer produces.
161
+ for existing in cache_dir.iterdir():
162
+ if existing.is_file() and existing.name not in staged:
163
+ existing.unlink()
164
+
165
+ (cache_dir / "meta.json").write_text(self.generate_key(source_path), encoding="utf-8")
166
+ shutil.rmtree(temp_dir, ignore_errors=True)
167
+
168
+ @staticmethod
169
+ def _recover_interrupted_swap(cache_dir: Path) -> None:
170
+ """Restore the most recent valid-sidecar backup after a process interruption."""
171
+ if cache_dir.exists():
172
+ return
173
+ backups = sorted(
174
+ cache_dir.parent.glob(f".{cache_dir.name}.backup-*"),
175
+ key=lambda path: path.stat().st_mtime,
176
+ reverse=True,
177
+ )
178
+ if not backups:
179
+ return
180
+ try:
181
+ os.replace(backups[0], cache_dir)
182
+ except OSError:
183
+ return
@@ -0,0 +1,165 @@
1
+ """Master-clock presentation of a cached pyramid channel.
2
+
3
+ A :class:`~avialsync.core.pyramid.PyramidReader` speaks its source's own time
4
+ base — whatever the acquisition system wrote. Video sources have always been
5
+ projected onto the master clock through a :class:`~avialsync.core.timeline.TimeMap`;
6
+ P3.5 gives time-series sources the same treatment so a sensor recorded on an
7
+ independent clock can be aligned without ever rewriting a cached sample.
8
+
9
+ :class:`MappedChannelReader` is drop-in compatible with ``PyramidReader``: every
10
+ method takes and returns **master** time. Consumers (plot rows, readout, delta
11
+ measurement, export, statistics) therefore need no mapping logic of their own.
12
+
13
+ Only bounded results are converted. ``mapped_columns`` deliberately stays in
14
+ source time — converting a whole recording would allocate a second copy of it —
15
+ so tick-rate consumers convert their scalar query instead via :attr:`time_map`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ from avialsync.core.pyramid import RAW_CHUNK_SAMPLES, PyramidReader
27
+ from avialsync.core.timeline import TimeMap
28
+
29
+
30
+ @dataclass(frozen=True, slots=True, order=True)
31
+ class ChannelKey:
32
+ """Stable identity of one channel: its source plus its name.
33
+
34
+ A channel name alone is not unique. Two recordings of the same rig both
35
+ contain ``force_z``; keying plots, readouts, units, visibility, or exports by
36
+ the bare name lets one file silently overwrite or control the other's row.
37
+ Every such map is keyed by this pair instead (P3.5 P1 identity).
38
+ """
39
+
40
+ source_id: str
41
+ channel_id: str
42
+
43
+ def label(self, ambiguous: bool = False) -> str:
44
+ """Return the display name, qualified by source only when it must be."""
45
+ if not ambiguous or not self.source_id:
46
+ return self.channel_id
47
+ return f"{self.channel_id} · {Path(self.source_id).name}"
48
+
49
+
50
+ def disambiguate(keys: list[ChannelKey]) -> dict[ChannelKey, str]:
51
+ """Return display labels, qualifying only names owned by more than one source."""
52
+ owners: dict[str, set[str]] = {}
53
+ for key in keys:
54
+ owners.setdefault(key.channel_id, set()).add(key.source_id)
55
+ return {key: key.label(len(owners[key.channel_id]) > 1) for key in keys}
56
+
57
+
58
+ class MappedChannelReader:
59
+ """A pyramid channel presented on the master clock through its ``TimeMap``."""
60
+
61
+ def __init__(
62
+ self,
63
+ reader: PyramidReader,
64
+ time_map: TimeMap | None = None,
65
+ source_id: str = "",
66
+ ) -> None:
67
+ self._reader = reader
68
+ self._time_map = time_map if time_map is not None else TimeMap()
69
+ self._source_id = source_id
70
+
71
+ # ── Identity (delegated so existing grouping/lookup keeps working) ─
72
+
73
+ @property
74
+ def cache_dir(self) -> Path:
75
+ return self._reader.cache_dir
76
+
77
+ @property
78
+ def channel_id(self) -> str:
79
+ return self._reader.channel_id
80
+
81
+ @property
82
+ def source_id(self) -> str:
83
+ """The owning source's stable identifier (its path)."""
84
+ return self._source_id
85
+
86
+ @property
87
+ def key(self) -> ChannelKey:
88
+ """The ``(source_id, channel_id)`` identity of this channel."""
89
+ return ChannelKey(self._source_id, self._reader.channel_id)
90
+
91
+ @property
92
+ def source_reader(self) -> PyramidReader:
93
+ """The underlying source-time reader."""
94
+ return self._reader
95
+
96
+ @property
97
+ def time_map(self) -> TimeMap:
98
+ """The source-to-master mapping applied by every method here."""
99
+ return self._time_map
100
+
101
+ def set_mapping(self, offset: float, drift_ppm: float) -> None:
102
+ """Replace the offset/drift mapping in place.
103
+
104
+ Existing plot rows and readout rows keep their reader object, so a live
105
+ offset edit is a mapping change rather than a channel reload.
106
+ """
107
+ self._time_map.offset = float(offset)
108
+ self._time_map.drift_ppm = float(drift_ppm)
109
+
110
+ # ── Bounded read API, all in master time ──────────────────────────
111
+
112
+ def coverage(self) -> tuple[float, float] | None:
113
+ """Return this channel's master-time extent, or None when empty."""
114
+ bounds = self._reader.coverage()
115
+ if bounds is None:
116
+ return None
117
+ return self._time_map.to_master(bounds[0]), self._time_map.to_master(bounds[1])
118
+
119
+ def sample_count(self) -> int:
120
+ return self._reader.sample_count()
121
+
122
+ def sample_at(self, t_master: float) -> tuple[int, float] | None:
123
+ return self._reader.sample_at(self._time_map.to_source(t_master))
124
+
125
+ def value_at(self, t_master: float) -> float:
126
+ return self._reader.value_at(self._time_map.to_source(t_master))
127
+
128
+ def raw_slice(
129
+ self, t0_master: float, t1_master: float
130
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
131
+ """Return ``(t_master, v, gap)`` for a bounded master-time range."""
132
+ t, v, gap = self._reader.raw_slice(
133
+ self._time_map.to_source(t0_master), self._time_map.to_source(t1_master)
134
+ )
135
+ return self._time_map.to_master_array(t), v, gap
136
+
137
+ def iter_raw_chunks(
138
+ self,
139
+ chunk_size: int = RAW_CHUNK_SAMPLES,
140
+ t0: float | None = None,
141
+ t1: float | None = None,
142
+ ) -> Iterator[tuple[np.ndarray, np.ndarray]]:
143
+ """Yield bounded ``(t_master, v)`` chunks."""
144
+ source_t0 = None if t0 is None else self._time_map.to_source(t0)
145
+ source_t1 = None if t1 is None else self._time_map.to_source(t1)
146
+ for times, values in self._reader.iter_raw_chunks(chunk_size, source_t0, source_t1):
147
+ yield self._time_map.to_master_array(times), values
148
+
149
+ def query(
150
+ self, t0: float, t1: float, max_points: int
151
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
152
+ """Decimated master-time query; the result is bounded by *max_points*."""
153
+ t, vmin, vmax, gap = self._reader.query(
154
+ self._time_map.to_source(t0), self._time_map.to_source(t1), max_points
155
+ )
156
+ return self._time_map.to_master_array(t), vmin, vmax, gap
157
+
158
+ def mapped_columns(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
159
+ """Return level-1 mmap views in **source** time.
160
+
161
+ Kept unmapped on purpose: converting the whole time column would
162
+ materialise the recording. Convert the scalar query with
163
+ ``reader.time_map.to_source(t_master)`` instead.
164
+ """
165
+ return self._reader.mapped_columns()
@@ -0,0 +1,73 @@
1
+ """AvialSync exception hierarchy."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class AvialSyncError(Exception):
7
+ """Base exception for all AvialSync errors."""
8
+
9
+ pass
10
+
11
+
12
+ class NonMonotonicTimeError(AvialSyncError):
13
+ """Raised when time series timestamps go backwards."""
14
+
15
+ def __init__(self, message: str, row: int, context: Any = None):
16
+ super().__init__(message)
17
+ self.row = row
18
+ self.context = context
19
+
20
+
21
+ class SourceOpenError(AvialSyncError):
22
+ """Raised when a media or data source fails to open."""
23
+
24
+ pass
25
+
26
+
27
+ class CacheError(AvialSyncError):
28
+ """Raised when the sidecar binary cache encounters an error."""
29
+
30
+ pass
31
+
32
+
33
+ class CodecUnsupportedError(AvialSyncError):
34
+ """Raised when a video codec is not supported."""
35
+
36
+ pass
37
+
38
+
39
+ class MissingColumnError(AvialSyncError):
40
+ """Raised when a required CSV column is missing."""
41
+
42
+ def __init__(self, column: str, available: list[str]):
43
+ self.column = column
44
+ self.available = available
45
+ super().__init__(f"Column '{column}' not found. Available: {', '.join(available)}")
46
+
47
+
48
+ class FileUnreadableError(AvialSyncError):
49
+ """Raised when a file cannot be read or parsed."""
50
+
51
+ pass
52
+
53
+
54
+ class LoaderContractError(AvialSyncError):
55
+ """Raised when a source plugin violates the frozen v1 ingest contract.
56
+
57
+ Distinct from :class:`SourceOpenError`: the file is readable, but the loader
58
+ returned something the importer cannot align — mismatched chunk lengths,
59
+ a missing declared channel, or chunks that disagree about their timestamps.
60
+ The actionable fix is in the plugin, not in the user's data.
61
+ """
62
+
63
+
64
+ class SyncEvidenceError(AvialSyncError):
65
+ """Raised when synchronization evidence is malformed or insufficient."""
66
+
67
+ pass
68
+
69
+
70
+ class SyncAmbiguityError(SyncEvidenceError):
71
+ """Raised when event evidence supports multiple equally valid alignments."""
72
+
73
+ pass
@@ -0,0 +1,146 @@
1
+ """Headless dataclasses for import statistics and source integrity (D-020).
2
+
3
+ No PySide6 imports — enforced by test_headless_core.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ from typing import Any
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True)
13
+ class ImportReport:
14
+ """Statistics collected by ImportWorker during one source import."""
15
+
16
+ rows_parsed: int = 0
17
+ rows_dropped_duplicate: int = 0
18
+ rows_dropped_nonmonotonic: int = 0
19
+ gap_count: int = 0
20
+ nan_count: int = 0
21
+ sentinel_count: int = 0
22
+ gap_locations: tuple[float, ...] = ()
23
+ import_timestamp: float = 0.0
24
+
25
+ def as_dict(self) -> dict[str, Any]:
26
+ return {
27
+ "rows_parsed": self.rows_parsed,
28
+ "rows_dropped_duplicate": self.rows_dropped_duplicate,
29
+ "rows_dropped_nonmonotonic": self.rows_dropped_nonmonotonic,
30
+ "gap_count": self.gap_count,
31
+ "nan_count": self.nan_count,
32
+ "sentinel_count": self.sentinel_count,
33
+ "gap_locations": list(self.gap_locations),
34
+ "import_timestamp": self.import_timestamp,
35
+ }
36
+
37
+ @classmethod
38
+ def from_dict(cls, d: dict[str, Any]) -> ImportReport:
39
+ return cls(
40
+ rows_parsed=d.get("rows_parsed", 0),
41
+ rows_dropped_duplicate=d.get("rows_dropped_duplicate", 0),
42
+ rows_dropped_nonmonotonic=d.get("rows_dropped_nonmonotonic", 0),
43
+ gap_count=d.get("gap_count", 0),
44
+ nan_count=d.get("nan_count", 0),
45
+ sentinel_count=d.get("sentinel_count", 0),
46
+ gap_locations=tuple(d.get("gap_locations", [])),
47
+ import_timestamp=d.get("import_timestamp", 0.0),
48
+ )
49
+
50
+
51
+ @dataclasses.dataclass(frozen=True)
52
+ class IntegrityFlags:
53
+ """Anomaly flags for one loaded source.
54
+
55
+ Video flags (is_vfr, fps_mismatch) are set by MainWindow._load_video.
56
+ Data flags (has_gaps, fps_provisional) are set by ImportWorker.
57
+ drift_nonzero is set when the user assigns a non-zero drift to any source.
58
+ """
59
+
60
+ is_vfr: bool = False
61
+ fps_mismatch: bool = False
62
+ has_gaps: bool = False
63
+ drift_nonzero: bool = False
64
+ fps_provisional: bool = False
65
+
66
+ @property
67
+ def any_flag(self) -> bool:
68
+ return any(
69
+ [
70
+ self.is_vfr,
71
+ self.fps_mismatch,
72
+ self.has_gaps,
73
+ self.drift_nonzero,
74
+ self.fps_provisional,
75
+ ]
76
+ )
77
+
78
+ def flag_labels(self) -> list[str]:
79
+ labels = []
80
+ if self.is_vfr:
81
+ labels.append("Variable frame rate")
82
+ if self.fps_mismatch:
83
+ labels.append("Nominal ≠ measured fps")
84
+ if self.has_gaps:
85
+ labels.append("Data gaps detected")
86
+ if self.drift_nonzero:
87
+ labels.append("Non-zero drift set")
88
+ if self.fps_provisional:
89
+ labels.append("FPS is provisional (no video loaded)")
90
+ return labels
91
+
92
+ def as_dict(self) -> dict[str, Any]:
93
+ return {
94
+ "is_vfr": self.is_vfr,
95
+ "fps_mismatch": self.fps_mismatch,
96
+ "has_gaps": self.has_gaps,
97
+ "drift_nonzero": self.drift_nonzero,
98
+ "fps_provisional": self.fps_provisional,
99
+ }
100
+
101
+ @classmethod
102
+ def from_dict(cls, d: dict[str, Any]) -> IntegrityFlags:
103
+ return cls(
104
+ is_vfr=d.get("is_vfr", False),
105
+ fps_mismatch=d.get("fps_mismatch", False),
106
+ has_gaps=d.get("has_gaps", False),
107
+ drift_nonzero=d.get("drift_nonzero", False),
108
+ fps_provisional=d.get("fps_provisional", False),
109
+ )
110
+
111
+
112
+ @dataclasses.dataclass
113
+ class SourceInspection:
114
+ """All collected inspection data for one loaded source.
115
+
116
+ Not frozen because import_config is a mutable dict.
117
+ """
118
+
119
+ path: str
120
+ loader_id: str = ""
121
+ import_config: dict[str, Any] = dataclasses.field(default_factory=dict)
122
+ import_report: ImportReport | None = None
123
+ integrity_flags: IntegrityFlags = dataclasses.field(default_factory=IntegrityFlags)
124
+ fps_binding: str = "" # "provisional", "bound:<video_path>", or ""
125
+
126
+ def as_dict(self) -> dict[str, Any]:
127
+ return {
128
+ "path": self.path,
129
+ "loader_id": self.loader_id,
130
+ "import_config": dict(self.import_config),
131
+ "import_report": self.import_report.as_dict() if self.import_report else None,
132
+ "integrity_flags": self.integrity_flags.as_dict(),
133
+ "fps_binding": self.fps_binding,
134
+ }
135
+
136
+ @classmethod
137
+ def from_dict(cls, d: dict[str, Any]) -> SourceInspection:
138
+ report_d = d.get("import_report")
139
+ return cls(
140
+ path=d.get("path", ""),
141
+ loader_id=d.get("loader_id", ""),
142
+ import_config=dict(d.get("import_config", {})),
143
+ import_report=ImportReport.from_dict(report_d) if report_d else None,
144
+ integrity_flags=IntegrityFlags.from_dict(d.get("integrity_flags", {})),
145
+ fps_binding=d.get("fps_binding", ""),
146
+ )