PyOPIA 2.13.1__tar.gz → 2.15.0__tar.gz
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.
- {pyopia-2.13.1 → pyopia-2.15.0}/PKG-INFO +3 -2
- pyopia-2.15.0/pyopia/__init__.py +1 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/cf_metadata.json +49 -1
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/cli.py +24 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/io.py +3 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/process.py +7 -1
- pyopia-2.15.0/pyopia/realtime.py +229 -0
- pyopia-2.15.0/pyopia/tests/test_realtime.py +331 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyproject.toml +2 -1
- pyopia-2.13.1/pyopia/__init__.py +0 -1
- {pyopia-2.13.1 → pyopia-2.15.0}/.gitignore +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/LICENSE +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/README.md +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/auxillarydata.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/background.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/classify.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/dataexport/__init__.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/dataexport/ecotaxa.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/exampledata.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/__init__.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/common.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/holo.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/silcam.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/uvp.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/metadata.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/pipeline.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/plotting.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/simulator/__init__.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/simulator/silcam.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/statistics.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/__init__.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_auxillarydata.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_classify.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_io.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_notebooks.py +0 -0
- {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_pipeline.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyOPIA
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.15.0
|
|
4
4
|
Summary: A Python Ocean Particle Image Analysis toolbox.
|
|
5
5
|
Project-URL: Repository, https://github.com/sintef/pyopia
|
|
6
6
|
Project-URL: Documentation, https://pyopia.readthedocs.io
|
|
7
7
|
Author-email: Emlyn Davies <emlyn.davies@sintef.no>, "Alex Nimmo Smith@plymouth.ac.uk" <alex.nimmo.smith@plymouth.ac.uk>
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Keywords: Imaging,Measurement,Ocean,Particles,Size distribution
|
|
10
|
-
Requires-Python: ~=3.12
|
|
10
|
+
Requires-Python: ~=3.12.0
|
|
11
11
|
Requires-Dist: click<8.2.0
|
|
12
12
|
Requires-Dist: cmocean<4,>=3.0.3
|
|
13
13
|
Requires-Dist: dask>=2024.8.1
|
|
@@ -41,6 +41,7 @@ Requires-Dist: toml<0.11,>=0.10.2
|
|
|
41
41
|
Requires-Dist: tqdm<5,>=4.66.1
|
|
42
42
|
Requires-Dist: typer[all]<0.10,>=0.9.0
|
|
43
43
|
Requires-Dist: urllib3<2.0
|
|
44
|
+
Requires-Dist: watchdog>=6.0.0
|
|
44
45
|
Requires-Dist: xarray<2024,>=2023.12.0
|
|
45
46
|
Provides-Extra: classification
|
|
46
47
|
Requires-Dist: keras==3.9.1; extra == 'classification'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.15.0"
|
|
@@ -62,4 +62,52 @@
|
|
|
62
62
|
"standard_name": "timestamp",
|
|
63
63
|
"long_name": "Timestamp of particle observation",
|
|
64
64
|
"calculation_method": "Recorded during particle observation",
|
|
65
|
-
"pyopia_process_level": 0}
|
|
65
|
+
"pyopia_process_level": 0},
|
|
66
|
+
"filename": {
|
|
67
|
+
"standard_name": "filename",
|
|
68
|
+
"long_name": "Filename of the raw image",
|
|
69
|
+
"units": "",
|
|
70
|
+
"calculation_method": "Recorded during image loading",
|
|
71
|
+
"pyopia_process_level": 0},
|
|
72
|
+
"particle_count": {
|
|
73
|
+
"standard_name": "particle_count",
|
|
74
|
+
"long_name": "Number of particles detected in the image",
|
|
75
|
+
"units": "1",
|
|
76
|
+
"calculation_method": "Count of particles from segmentation",
|
|
77
|
+
"pyopia_process_level": 1},
|
|
78
|
+
"saturation": {
|
|
79
|
+
"standard_name": "image_saturation",
|
|
80
|
+
"long_name": "Percentage saturation of the image",
|
|
81
|
+
"units": "percent",
|
|
82
|
+
"calculation_method": "Computed as the percentage of the image covered by particles relative to the maximum acceptable coverage",
|
|
83
|
+
"pyopia_process_level": 1},
|
|
84
|
+
"d50": {
|
|
85
|
+
"standard_name": "median_particle_diameter",
|
|
86
|
+
"long_name": "Median particle diameter (D50) from volume distribution",
|
|
87
|
+
"units": "micrometers",
|
|
88
|
+
"calculation_method": "Calculated from volume-based particle size distribution",
|
|
89
|
+
"pyopia_process_level": 2},
|
|
90
|
+
"nc": {
|
|
91
|
+
"standard_name": "number_concentration",
|
|
92
|
+
"long_name": "Number concentration of particles",
|
|
93
|
+
"units": "L^-1",
|
|
94
|
+
"calculation_method": "Calculated from particle count and sample volume",
|
|
95
|
+
"pyopia_process_level": 2},
|
|
96
|
+
"vc": {
|
|
97
|
+
"standard_name": "volume_concentration",
|
|
98
|
+
"long_name": "Volume concentration of particles",
|
|
99
|
+
"units": "uL L^-1",
|
|
100
|
+
"calculation_method": "Calculated from particle volumes and sample volume",
|
|
101
|
+
"pyopia_process_level": 2},
|
|
102
|
+
"sample_volume": {
|
|
103
|
+
"standard_name": "sample_volume",
|
|
104
|
+
"long_name": "Volume of water sampled",
|
|
105
|
+
"units": "L",
|
|
106
|
+
"calculation_method": "Calculated from image area, pixel size, and path length",
|
|
107
|
+
"pyopia_process_level": 2},
|
|
108
|
+
"junge": {
|
|
109
|
+
"standard_name": "junge_parameter",
|
|
110
|
+
"long_name": "Junge parameter (slope of particle size number distribution)",
|
|
111
|
+
"units": "1",
|
|
112
|
+
"calculation_method": "Fitted slope of particle size number distribution between 150-350 µm",
|
|
113
|
+
"pyopia_process_level": 2}}
|
|
@@ -42,6 +42,7 @@ import pyopia.auxillarydata
|
|
|
42
42
|
import pyopia.exampledata
|
|
43
43
|
import pyopia.metadata
|
|
44
44
|
import pyopia.dataexport.ecotaxa
|
|
45
|
+
import pyopia.realtime
|
|
45
46
|
|
|
46
47
|
app = typer.Typer()
|
|
47
48
|
|
|
@@ -350,6 +351,29 @@ def process(config_filename: str, num_chunks: int = 1, strategy: str = "block"):
|
|
|
350
351
|
progress.console.print(f"[blue]PROCESSING COMPLETED IN {time_total}")
|
|
351
352
|
|
|
352
353
|
|
|
354
|
+
@app.command()
|
|
355
|
+
def process_realtime(config_filename: str, watch_folder: str = None):
|
|
356
|
+
"""Run a PyOPIA processing pipeline in realtime by watching a folder.
|
|
357
|
+
|
|
358
|
+
Parameters
|
|
359
|
+
----------
|
|
360
|
+
config_filename : str
|
|
361
|
+
Config filename
|
|
362
|
+
|
|
363
|
+
watch_folder : str, optional
|
|
364
|
+
Folder to monitor. If not provided, inferred from `general.raw_files` in config.
|
|
365
|
+
|
|
366
|
+
Notes
|
|
367
|
+
-----
|
|
368
|
+
- Single-core only: files are processed sequentially by a single worker thread.
|
|
369
|
+
- Uses `watchdog` moved events only (rename into place) to avoid half-written files.
|
|
370
|
+
"""
|
|
371
|
+
# Load config and setup logging
|
|
372
|
+
pipeline_config = pyopia.io.load_toml(config_filename)
|
|
373
|
+
setup_logging(pipeline_config)
|
|
374
|
+
pyopia.realtime.run_realtime(pipeline_config, watch_folder=watch_folder)
|
|
375
|
+
|
|
376
|
+
|
|
353
377
|
@app.command()
|
|
354
378
|
def merge_mfdata(
|
|
355
379
|
path_to_data: str,
|
|
@@ -128,6 +128,9 @@ def write_stats(
|
|
|
128
128
|
# Add auxillary data to ximage_stats
|
|
129
129
|
ximage_stats = auxillary_data.add_auxillary_data_to_xstats(ximage_stats)
|
|
130
130
|
|
|
131
|
+
# Add CF-compliant attributes
|
|
132
|
+
ximage_stats = add_cf_attributes(ximage_stats)
|
|
133
|
+
|
|
131
134
|
encoding_imagestats = setup_xstats_encoding(ximage_stats)
|
|
132
135
|
ximage_stats.to_netcdf(
|
|
133
136
|
datafilename + "-STATS.nc",
|
|
@@ -639,7 +639,13 @@ class CalculateImageStats():
|
|
|
639
639
|
|
|
640
640
|
path_length = getattr(data['settings']['general'], 'path_length', 40)
|
|
641
641
|
if path_length is not None:
|
|
642
|
-
|
|
642
|
+
# Get image dimensions from the corrected image
|
|
643
|
+
image = data.get('imraw')
|
|
644
|
+
if image is not None:
|
|
645
|
+
imy, imx = image.shape[:2]
|
|
646
|
+
else:
|
|
647
|
+
imx, imy = 2048, 2448 # fallback defaults
|
|
648
|
+
nc_vc = pyopia.statistics.nc_vc_from_stats(data['stats'], pixel_size, path_length, imx=imx, imy=imy)
|
|
643
649
|
for k, v in zip(['nc', 'vc', 'sample_volume', 'junge'], nc_vc):
|
|
644
650
|
data['image_stats'].loc[data['timestamp'], k] = v
|
|
645
651
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Utilities for running PyOPIA pipelines in realtime mode."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import logging
|
|
5
|
+
import pathlib
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from rich import print as rich_print
|
|
12
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
13
|
+
from watchdog.events import FileSystemEventHandler
|
|
14
|
+
from watchdog.observers import Observer
|
|
15
|
+
|
|
16
|
+
import pyopia.pipeline
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_watch_settings(raw_files_pattern: str, watch_folder: str | None) -> tuple[str, str]:
|
|
20
|
+
raw_path = pathlib.Path(raw_files_pattern)
|
|
21
|
+
file_pattern = raw_path.name if raw_path.name else "*"
|
|
22
|
+
inferred_watch = str(raw_path.parent) if str(raw_path.parent) != "." else "."
|
|
23
|
+
|
|
24
|
+
if watch_folder is None:
|
|
25
|
+
watch_folder = inferred_watch
|
|
26
|
+
|
|
27
|
+
return watch_folder, file_pattern
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _enqueue_file_if_new(
|
|
31
|
+
file_path: pathlib.Path,
|
|
32
|
+
file_queue: queue.Queue,
|
|
33
|
+
file_pattern: str,
|
|
34
|
+
seen_files: set[str],
|
|
35
|
+
seen_lock: threading.Lock,
|
|
36
|
+
) -> bool:
|
|
37
|
+
if not file_path.exists() or file_path.is_dir():
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
if not fnmatch.fnmatch(file_path.name, file_pattern):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
file_key = file_path.as_posix()
|
|
44
|
+
with seen_lock:
|
|
45
|
+
if file_key in seen_files:
|
|
46
|
+
return False
|
|
47
|
+
seen_files.add(file_key)
|
|
48
|
+
|
|
49
|
+
file_queue.put(file_path)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _enqueue_existing_files(
|
|
54
|
+
watch_folder: str,
|
|
55
|
+
file_pattern: str,
|
|
56
|
+
file_queue: queue.Queue,
|
|
57
|
+
seen_files: set[str],
|
|
58
|
+
seen_lock: threading.Lock,
|
|
59
|
+
logger: logging.Logger,
|
|
60
|
+
):
|
|
61
|
+
for file_path in sorted(pathlib.Path(watch_folder).glob(file_pattern)):
|
|
62
|
+
if _enqueue_file_if_new(file_path, file_queue, file_pattern, seen_files, seen_lock):
|
|
63
|
+
logger.info(f"Existing file queued: {file_path}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_event_handler(
|
|
67
|
+
file_queue: queue.Queue,
|
|
68
|
+
file_pattern: str,
|
|
69
|
+
seen_files: set[str],
|
|
70
|
+
seen_lock: threading.Lock,
|
|
71
|
+
logger: logging.Logger,
|
|
72
|
+
):
|
|
73
|
+
class NewFileHandler(FileSystemEventHandler):
|
|
74
|
+
def _handle_path(self, path):
|
|
75
|
+
try:
|
|
76
|
+
file_path = pathlib.Path(path)
|
|
77
|
+
if _enqueue_file_if_new(
|
|
78
|
+
file_path,
|
|
79
|
+
file_queue,
|
|
80
|
+
file_pattern,
|
|
81
|
+
seen_files,
|
|
82
|
+
seen_lock,
|
|
83
|
+
):
|
|
84
|
+
logger.info(f"New file detected: {file_path}")
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Error handling filesystem event")
|
|
87
|
+
|
|
88
|
+
def on_moved(self, event):
|
|
89
|
+
destination = getattr(event, "dest_path", None)
|
|
90
|
+
if destination:
|
|
91
|
+
self._handle_path(destination)
|
|
92
|
+
|
|
93
|
+
return NewFileHandler()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _worker_loop(
|
|
97
|
+
stop_event: threading.Event,
|
|
98
|
+
file_queue: queue.Queue,
|
|
99
|
+
processing_pipeline: pyopia.pipeline.Pipeline,
|
|
100
|
+
logger: logging.Logger,
|
|
101
|
+
runtime_state: dict,
|
|
102
|
+
state_lock: threading.Lock,
|
|
103
|
+
):
|
|
104
|
+
while not stop_event.is_set():
|
|
105
|
+
try:
|
|
106
|
+
filepath = file_queue.get(timeout=1)
|
|
107
|
+
except queue.Empty:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with state_lock:
|
|
112
|
+
runtime_state["current_file"] = filepath.name
|
|
113
|
+
|
|
114
|
+
start = time.time()
|
|
115
|
+
logger.info(f"Starting processing: {filepath.name}")
|
|
116
|
+
processing_pipeline.run(filepath.as_posix())
|
|
117
|
+
elapsed = time.time() - start
|
|
118
|
+
logger.info(f"Completed {filepath.name} in {elapsed:.1f}s")
|
|
119
|
+
with state_lock:
|
|
120
|
+
runtime_state["processed_count"] += 1
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.exception(f"Error processing {filepath}: {exc}")
|
|
123
|
+
finally:
|
|
124
|
+
with state_lock:
|
|
125
|
+
runtime_state["current_file"] = "idle"
|
|
126
|
+
file_queue.task_done()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_realtime(pipeline_config: dict, watch_folder: str | None = None):
|
|
130
|
+
"""Run a PyOPIA processing pipeline in realtime by watching a folder.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
pipeline_config : dict
|
|
135
|
+
Loaded PyOPIA pipeline config.
|
|
136
|
+
watch_folder : str, optional
|
|
137
|
+
Folder to monitor. If not provided, inferred from ``general.raw_files``.
|
|
138
|
+
"""
|
|
139
|
+
logger = logging.getLogger("rich")
|
|
140
|
+
logger.info(f"PyOPIA realtime process started {pd.Timestamp.now()}")
|
|
141
|
+
t1 = time.time()
|
|
142
|
+
|
|
143
|
+
raw_files_pattern = pipeline_config["general"].get("raw_files", "*")
|
|
144
|
+
watch_folder, file_pattern = _resolve_watch_settings(raw_files_pattern, watch_folder)
|
|
145
|
+
|
|
146
|
+
processing_pipeline = pyopia.pipeline.Pipeline(pipeline_config)
|
|
147
|
+
|
|
148
|
+
file_queue = queue.Queue()
|
|
149
|
+
stop_event = threading.Event()
|
|
150
|
+
seen_files: set[str] = set()
|
|
151
|
+
seen_lock = threading.Lock()
|
|
152
|
+
runtime_state = {"processed_count": 0, "current_file": "idle"}
|
|
153
|
+
state_lock = threading.Lock()
|
|
154
|
+
|
|
155
|
+
observer = Observer()
|
|
156
|
+
handler = _build_event_handler(
|
|
157
|
+
file_queue,
|
|
158
|
+
file_pattern,
|
|
159
|
+
seen_files,
|
|
160
|
+
seen_lock,
|
|
161
|
+
logger,
|
|
162
|
+
)
|
|
163
|
+
observer.schedule(handler, path=watch_folder, recursive=False)
|
|
164
|
+
observer.start()
|
|
165
|
+
|
|
166
|
+
_enqueue_existing_files(
|
|
167
|
+
watch_folder,
|
|
168
|
+
file_pattern,
|
|
169
|
+
file_queue,
|
|
170
|
+
seen_files,
|
|
171
|
+
seen_lock,
|
|
172
|
+
logger,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
worker_thread = threading.Thread(
|
|
176
|
+
target=_worker_loop,
|
|
177
|
+
args=(
|
|
178
|
+
stop_event,
|
|
179
|
+
file_queue,
|
|
180
|
+
processing_pipeline,
|
|
181
|
+
logger,
|
|
182
|
+
runtime_state,
|
|
183
|
+
state_lock,
|
|
184
|
+
),
|
|
185
|
+
daemon=True,
|
|
186
|
+
)
|
|
187
|
+
worker_thread.start()
|
|
188
|
+
|
|
189
|
+
logger.info(
|
|
190
|
+
f"Watching folder {watch_folder} for moved files matching '{file_pattern}'"
|
|
191
|
+
)
|
|
192
|
+
rich_print(
|
|
193
|
+
f"[blue]Realtime started. Watching {watch_folder} for moved files matching '{file_pattern}'"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
with Progress(
|
|
197
|
+
SpinnerColumn(),
|
|
198
|
+
TextColumn("{task.description}"),
|
|
199
|
+
BarColumn(),
|
|
200
|
+
TimeElapsedColumn(),
|
|
201
|
+
transient=True,
|
|
202
|
+
) as progress:
|
|
203
|
+
task_id = progress.add_task("[blue]Realtime active", total=None)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
while True:
|
|
207
|
+
with state_lock:
|
|
208
|
+
processed_count = runtime_state["processed_count"]
|
|
209
|
+
current_file = runtime_state["current_file"]
|
|
210
|
+
progress.update(
|
|
211
|
+
task_id,
|
|
212
|
+
description=(
|
|
213
|
+
"[blue]Realtime active"
|
|
214
|
+
f" | processed: {processed_count}"
|
|
215
|
+
f" | queued: {file_queue.qsize()}"
|
|
216
|
+
f" | current: {current_file}"
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
time.sleep(1)
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
logger.info("Shutdown requested, stopping observer and worker...")
|
|
222
|
+
finally:
|
|
223
|
+
stop_event.set()
|
|
224
|
+
observer.stop()
|
|
225
|
+
observer.join()
|
|
226
|
+
worker_thread.join(timeout=5)
|
|
227
|
+
|
|
228
|
+
time_total = pd.to_timedelta(time.time() - t1, "seconds")
|
|
229
|
+
progress.console.print(f"[blue]REALTIME PROCESSING STOPPED AFTER {time_total}")
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import queue
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pyopia.realtime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _DummyObserver:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.scheduled_path = None
|
|
12
|
+
self.recursive = None
|
|
13
|
+
self.started = False
|
|
14
|
+
self.stopped = False
|
|
15
|
+
self.joined = False
|
|
16
|
+
|
|
17
|
+
def schedule(self, _handler, path, recursive):
|
|
18
|
+
self.scheduled_path = path
|
|
19
|
+
self.recursive = recursive
|
|
20
|
+
|
|
21
|
+
def start(self):
|
|
22
|
+
self.started = True
|
|
23
|
+
|
|
24
|
+
def stop(self):
|
|
25
|
+
self.stopped = True
|
|
26
|
+
|
|
27
|
+
def join(self):
|
|
28
|
+
self.joined = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _DummyThread:
|
|
32
|
+
def __init__(self, target, args, daemon):
|
|
33
|
+
self.target = target
|
|
34
|
+
self.args = args
|
|
35
|
+
self.daemon = daemon
|
|
36
|
+
self.started = False
|
|
37
|
+
self.join_timeout = None
|
|
38
|
+
|
|
39
|
+
def start(self):
|
|
40
|
+
self.started = True
|
|
41
|
+
|
|
42
|
+
def join(self, timeout=None):
|
|
43
|
+
self.join_timeout = timeout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DummyProgress:
|
|
47
|
+
def __init__(self, *args, transient=True):
|
|
48
|
+
self.args = args
|
|
49
|
+
self.transient = transient
|
|
50
|
+
self.console = self
|
|
51
|
+
self.task_id = 1
|
|
52
|
+
self.descriptions = []
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, exc_type, exc, tb):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def print(self, *_args, **_kwargs):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def add_task(self, description, total=None):
|
|
64
|
+
self.descriptions.append(description)
|
|
65
|
+
return self.task_id
|
|
66
|
+
|
|
67
|
+
def update(self, _task_id, description=None):
|
|
68
|
+
if description is not None:
|
|
69
|
+
self.descriptions.append(description)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _DummyPipeline:
|
|
73
|
+
def __init__(self, config):
|
|
74
|
+
self.config = config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _patch_realtime_runtime(monkeypatch, created):
|
|
78
|
+
def observer_factory():
|
|
79
|
+
created["observer"] = _DummyObserver()
|
|
80
|
+
return created["observer"]
|
|
81
|
+
|
|
82
|
+
def thread_factory(target, args, daemon):
|
|
83
|
+
created["thread"] = _DummyThread(target, args, daemon)
|
|
84
|
+
return created["thread"]
|
|
85
|
+
|
|
86
|
+
def raise_keyboard_interrupt(_seconds):
|
|
87
|
+
raise KeyboardInterrupt()
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(pyopia.realtime, "Observer", observer_factory)
|
|
90
|
+
monkeypatch.setattr(pyopia.realtime.threading, "Thread", thread_factory)
|
|
91
|
+
monkeypatch.setattr(pyopia.realtime, "Progress", _DummyProgress)
|
|
92
|
+
monkeypatch.setattr(pyopia.realtime.pyopia.pipeline, "Pipeline", _DummyPipeline)
|
|
93
|
+
monkeypatch.setattr(pyopia.realtime.time, "sleep", raise_keyboard_interrupt)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_resolve_watch_settings_infers_from_raw_pattern(tmp_path: Path):
|
|
97
|
+
images_dir = tmp_path / "images"
|
|
98
|
+
images_dir.mkdir()
|
|
99
|
+
|
|
100
|
+
raw_pattern = str(images_dir / "*.silc")
|
|
101
|
+
watch_folder, file_pattern = pyopia.realtime._resolve_watch_settings(raw_pattern, None)
|
|
102
|
+
|
|
103
|
+
assert watch_folder == str(images_dir)
|
|
104
|
+
assert file_pattern == "*.silc"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_resolve_watch_settings_prefers_explicit_watch_folder(tmp_path: Path):
|
|
108
|
+
images_dir = tmp_path / "images"
|
|
109
|
+
images_dir.mkdir()
|
|
110
|
+
|
|
111
|
+
raw_pattern = str(images_dir / "*.silc")
|
|
112
|
+
watch_folder, file_pattern = pyopia.realtime._resolve_watch_settings(
|
|
113
|
+
raw_pattern,
|
|
114
|
+
watch_folder="/custom/watch",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert watch_folder == "/custom/watch"
|
|
118
|
+
assert file_pattern == "*.silc"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_event_handler_enqueues_only_matching_moved_files(tmp_path: Path):
|
|
122
|
+
file_queue = queue.Queue()
|
|
123
|
+
logger = logging.getLogger("test")
|
|
124
|
+
seen_files = set()
|
|
125
|
+
seen_lock = threading.Lock()
|
|
126
|
+
handler = pyopia.realtime._build_event_handler(
|
|
127
|
+
file_queue,
|
|
128
|
+
"*.silc",
|
|
129
|
+
seen_files,
|
|
130
|
+
seen_lock,
|
|
131
|
+
logger,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
matched = tmp_path / "image.silc"
|
|
135
|
+
matched.write_text("ok")
|
|
136
|
+
ignored = tmp_path / "other.txt"
|
|
137
|
+
ignored.write_text("skip")
|
|
138
|
+
|
|
139
|
+
moved_event_match = type("Event", (), {"dest_path": str(matched)})()
|
|
140
|
+
moved_event_no_match = type("Event", (), {"dest_path": str(ignored)})()
|
|
141
|
+
|
|
142
|
+
handler.on_moved(moved_event_match)
|
|
143
|
+
handler.on_moved(moved_event_no_match)
|
|
144
|
+
|
|
145
|
+
queued = file_queue.get_nowait()
|
|
146
|
+
assert queued == matched
|
|
147
|
+
assert file_queue.empty()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_event_handler_deduplicates_same_moved_file(tmp_path: Path):
|
|
151
|
+
file_queue = queue.Queue()
|
|
152
|
+
logger = logging.getLogger("test")
|
|
153
|
+
seen_files = set()
|
|
154
|
+
seen_lock = threading.Lock()
|
|
155
|
+
handler = pyopia.realtime._build_event_handler(
|
|
156
|
+
file_queue,
|
|
157
|
+
"*.silc",
|
|
158
|
+
seen_files,
|
|
159
|
+
seen_lock,
|
|
160
|
+
logger,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
matched = tmp_path / "image.silc"
|
|
164
|
+
matched.write_text("ok")
|
|
165
|
+
moved_event = type("Event", (), {"dest_path": str(matched)})()
|
|
166
|
+
|
|
167
|
+
handler.on_moved(moved_event)
|
|
168
|
+
handler.on_moved(moved_event)
|
|
169
|
+
|
|
170
|
+
queued = file_queue.get_nowait()
|
|
171
|
+
assert queued == matched
|
|
172
|
+
assert file_queue.empty()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_enqueue_existing_files_matches_pattern_and_deduplicates(tmp_path: Path):
|
|
176
|
+
file_queue = queue.Queue()
|
|
177
|
+
seen_files = set()
|
|
178
|
+
seen_lock = threading.Lock()
|
|
179
|
+
logger = logging.getLogger("test")
|
|
180
|
+
|
|
181
|
+
matched = tmp_path / "a.silc"
|
|
182
|
+
matched.write_text("ok")
|
|
183
|
+
ignored = tmp_path / "b.txt"
|
|
184
|
+
ignored.write_text("skip")
|
|
185
|
+
|
|
186
|
+
pyopia.realtime._enqueue_existing_files(
|
|
187
|
+
str(tmp_path),
|
|
188
|
+
"*.silc",
|
|
189
|
+
file_queue,
|
|
190
|
+
seen_files,
|
|
191
|
+
seen_lock,
|
|
192
|
+
logger,
|
|
193
|
+
)
|
|
194
|
+
pyopia.realtime._enqueue_existing_files(
|
|
195
|
+
str(tmp_path),
|
|
196
|
+
"*.silc",
|
|
197
|
+
file_queue,
|
|
198
|
+
seen_files,
|
|
199
|
+
seen_lock,
|
|
200
|
+
logger,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
queued = file_queue.get_nowait()
|
|
204
|
+
assert queued == matched
|
|
205
|
+
assert file_queue.empty()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_integration_existing_then_moved_files_processed_once(tmp_path: Path):
|
|
209
|
+
file_queue = queue.Queue()
|
|
210
|
+
seen_files = set()
|
|
211
|
+
seen_lock = threading.Lock()
|
|
212
|
+
logger = logging.getLogger("test")
|
|
213
|
+
|
|
214
|
+
existing = tmp_path / "existing.silc"
|
|
215
|
+
existing.write_text("old")
|
|
216
|
+
moved_in = tmp_path / "moved.silc"
|
|
217
|
+
moved_in.write_text("new")
|
|
218
|
+
|
|
219
|
+
pyopia.realtime._enqueue_existing_files(
|
|
220
|
+
str(tmp_path),
|
|
221
|
+
"*.silc",
|
|
222
|
+
file_queue,
|
|
223
|
+
seen_files,
|
|
224
|
+
seen_lock,
|
|
225
|
+
logger,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
handler = pyopia.realtime._build_event_handler(
|
|
229
|
+
file_queue,
|
|
230
|
+
"*.silc",
|
|
231
|
+
seen_files,
|
|
232
|
+
seen_lock,
|
|
233
|
+
logger,
|
|
234
|
+
)
|
|
235
|
+
handler.on_moved(type("Event", (), {"dest_path": str(moved_in)})())
|
|
236
|
+
handler.on_moved(type("Event", (), {"dest_path": str(existing)})())
|
|
237
|
+
|
|
238
|
+
stop_event = threading.Event()
|
|
239
|
+
processed = []
|
|
240
|
+
runtime_state = {"processed_count": 0, "current_file": "idle"}
|
|
241
|
+
state_lock = threading.Lock()
|
|
242
|
+
|
|
243
|
+
class DummyPipeline:
|
|
244
|
+
def run(self, filename):
|
|
245
|
+
processed.append(filename)
|
|
246
|
+
if len(processed) == 2:
|
|
247
|
+
stop_event.set()
|
|
248
|
+
|
|
249
|
+
pyopia.realtime._worker_loop(
|
|
250
|
+
stop_event=stop_event,
|
|
251
|
+
file_queue=file_queue,
|
|
252
|
+
processing_pipeline=DummyPipeline(),
|
|
253
|
+
logger=logger,
|
|
254
|
+
runtime_state=runtime_state,
|
|
255
|
+
state_lock=state_lock,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
assert set(processed) == {existing.as_posix(), moved_in.as_posix()}
|
|
259
|
+
assert runtime_state["processed_count"] == 2
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_worker_loop_processes_one_file_and_uses_posix_path(tmp_path: Path):
|
|
263
|
+
class DummyPipeline:
|
|
264
|
+
def __init__(self):
|
|
265
|
+
self.processed = []
|
|
266
|
+
|
|
267
|
+
def run(self, filename):
|
|
268
|
+
self.processed.append(filename)
|
|
269
|
+
stop_event.set()
|
|
270
|
+
|
|
271
|
+
image_file = tmp_path / "sample.silc"
|
|
272
|
+
image_file.write_text("content")
|
|
273
|
+
|
|
274
|
+
stop_event = threading.Event()
|
|
275
|
+
file_queue = queue.Queue()
|
|
276
|
+
file_queue.put(image_file)
|
|
277
|
+
pipeline = DummyPipeline()
|
|
278
|
+
runtime_state = {"processed_count": 0, "current_file": "idle"}
|
|
279
|
+
state_lock = threading.Lock()
|
|
280
|
+
|
|
281
|
+
pyopia.realtime._worker_loop(
|
|
282
|
+
stop_event=stop_event,
|
|
283
|
+
file_queue=file_queue,
|
|
284
|
+
processing_pipeline=pipeline,
|
|
285
|
+
logger=logging.getLogger("test"),
|
|
286
|
+
runtime_state=runtime_state,
|
|
287
|
+
state_lock=state_lock,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
assert pipeline.processed == [image_file.as_posix()]
|
|
291
|
+
assert runtime_state["processed_count"] == 1
|
|
292
|
+
assert runtime_state["current_file"] == "idle"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_run_realtime_schedules_observer_and_stops_on_keyboard_interrupt(monkeypatch, tmp_path: Path):
|
|
296
|
+
images_dir = tmp_path / "incoming"
|
|
297
|
+
images_dir.mkdir()
|
|
298
|
+
pipeline_config = {
|
|
299
|
+
"general": {
|
|
300
|
+
"raw_files": str(images_dir / "*.silc"),
|
|
301
|
+
},
|
|
302
|
+
"steps": {},
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
created = {}
|
|
306
|
+
progress_calls = {"added": 0, "updated": 0}
|
|
307
|
+
|
|
308
|
+
class TrackingProgress(_DummyProgress):
|
|
309
|
+
def add_task(self, description, total=None):
|
|
310
|
+
progress_calls["added"] += 1
|
|
311
|
+
return super().add_task(description, total)
|
|
312
|
+
|
|
313
|
+
def update(self, _task_id, description=None):
|
|
314
|
+
progress_calls["updated"] += 1
|
|
315
|
+
return super().update(_task_id, description)
|
|
316
|
+
|
|
317
|
+
_patch_realtime_runtime(monkeypatch, created)
|
|
318
|
+
monkeypatch.setattr(pyopia.realtime, "Progress", TrackingProgress)
|
|
319
|
+
|
|
320
|
+
pyopia.realtime.run_realtime(pipeline_config)
|
|
321
|
+
|
|
322
|
+
assert created["observer"].scheduled_path == str(images_dir)
|
|
323
|
+
assert created["observer"].recursive is False
|
|
324
|
+
assert created["observer"].started is True
|
|
325
|
+
assert created["observer"].stopped is True
|
|
326
|
+
assert created["observer"].joined is True
|
|
327
|
+
assert created["thread"].started is True
|
|
328
|
+
assert created["thread"].daemon is True
|
|
329
|
+
assert created["thread"].join_timeout == 5
|
|
330
|
+
assert progress_calls["added"] == 1
|
|
331
|
+
assert progress_calls["updated"] >= 1
|
|
@@ -6,7 +6,7 @@ authors = [
|
|
|
6
6
|
{ name = "Emlyn Davies", email = "emlyn.davies@sintef.no" },
|
|
7
7
|
{ name = "Alex Nimmo Smith@plymouth.ac.uk", email = "alex.nimmo.smith@plymouth.ac.uk" },
|
|
8
8
|
]
|
|
9
|
-
requires-python = "~=3.12"
|
|
9
|
+
requires-python = "~=3.12.0"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
keywords = [
|
|
12
12
|
"Ocean",
|
|
@@ -50,6 +50,7 @@ dependencies = [
|
|
|
50
50
|
"click<8.2.0",
|
|
51
51
|
"seaborn>=0.13.2",
|
|
52
52
|
"pydantic>=2.11.7",
|
|
53
|
+
"watchdog>=6.0.0",
|
|
53
54
|
]
|
|
54
55
|
|
|
55
56
|
[project.optional-dependencies]
|
pyopia-2.13.1/pyopia/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.13.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|