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.
- avialsync/__init__.py +3 -0
- avialsync/__main__.py +117 -0
- avialsync/core/__init__.py +1 -0
- avialsync/core/cache.py +183 -0
- avialsync/core/channel_reader.py +165 -0
- avialsync/core/errors.py +73 -0
- avialsync/core/inspection.py +146 -0
- avialsync/core/pyramid.py +508 -0
- avialsync/core/registry.py +150 -0
- avialsync/core/session.py +257 -0
- avialsync/core/source.py +173 -0
- avialsync/core/sync.py +374 -0
- avialsync/core/timeline.py +299 -0
- avialsync/demo.py +531 -0
- avialsync/engine/__init__.py +1 -0
- avialsync/engine/drop_worker.py +279 -0
- avialsync/engine/export.py +227 -0
- avialsync/engine/export_worker.py +200 -0
- avialsync/engine/importer.py +312 -0
- avialsync/engine/player.py +433 -0
- avialsync/engine/proxy.py +106 -0
- avialsync/engine/seeker.py +40 -0
- avialsync/engine/session_worker.py +70 -0
- avialsync/engine/sync_worker.py +96 -0
- avialsync/engine/video_worker.py +65 -0
- avialsync/loaders/__init__.py +1 -0
- avialsync/loaders/aol_eks_loader.py +259 -0
- avialsync/loaders/aol_encoder_loader.py +287 -0
- avialsync/loaders/aol_session_loader.py +356 -0
- avialsync/loaders/csv_loader.py +339 -0
- avialsync/loaders/neo_loader.py +328 -0
- avialsync/loaders/tracking_loader.py +152 -0
- avialsync/loaders/video_standard.py +276 -0
- avialsync/resources/__init__.py +1 -0
- avialsync/resources/avialsync.png +0 -0
- avialsync/runtime.py +161 -0
- avialsync/ui/__init__.py +1 -0
- avialsync/ui/annotations.py +211 -0
- avialsync/ui/batch_import_dialog.py +130 -0
- avialsync/ui/diagnostics.py +213 -0
- avialsync/ui/import_report.py +105 -0
- avialsync/ui/import_wizard.py +397 -0
- avialsync/ui/job_manager.py +297 -0
- avialsync/ui/main_window.py +2884 -0
- avialsync/ui/offsets_panel.py +1 -0
- avialsync/ui/pane_proportions.py +199 -0
- avialsync/ui/plot_header.py +69 -0
- avialsync/ui/plot_interactions.py +142 -0
- avialsync/ui/plot_overlays.py +177 -0
- avialsync/ui/plot_pane.py +812 -0
- avialsync/ui/plot_row.py +274 -0
- avialsync/ui/plot_sweep.py +342 -0
- avialsync/ui/readout_panel.py +335 -0
- avialsync/ui/recent_files.py +40 -0
- avialsync/ui/relink_dialog.py +118 -0
- avialsync/ui/shortcuts_dialog.py +92 -0
- avialsync/ui/sidebar.py +535 -0
- avialsync/ui/source_properties.py +237 -0
- avialsync/ui/sync_wizard.py +219 -0
- avialsync/ui/theme.py +365 -0
- avialsync/ui/time_format.py +43 -0
- avialsync/ui/tracking_3d_pane.py +648 -0
- avialsync/ui/tracking_colors.py +111 -0
- avialsync/ui/transport.py +948 -0
- avialsync/ui/ui_heartbeat.py +83 -0
- avialsync/ui/video_grid.py +353 -0
- avialsync/ui/video_overlay.py +272 -0
- avialsync/ui/video_pane.py +608 -0
- avialsync/ui/video_timing.py +282 -0
- avialsync-0.1.0b5.dist-info/METADATA +236 -0
- avialsync-0.1.0b5.dist-info/RECORD +74 -0
- avialsync-0.1.0b5.dist-info/WHEEL +4 -0
- avialsync-0.1.0b5.dist-info/entry_points.txt +10 -0
- avialsync-0.1.0b5.dist-info/licenses/LICENSE +3 -0
avialsync/__init__.py
ADDED
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."""
|
avialsync/core/cache.py
ADDED
|
@@ -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()
|
avialsync/core/errors.py
ADDED
|
@@ -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
|
+
)
|