svc-processing 0.1.1__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.
- pipeline/__init__.py +0 -0
- pipeline/cli.py +92 -0
- pipeline/processor.py +582 -0
- pipeline/resampler.py +495 -0
- pipeline/run_config.py +303 -0
- pipeline/runner.py +216 -0
- pipeline/sig_processor.py +258 -0
- svc_processing-0.1.1.dist-info/METADATA +250 -0
- svc_processing-0.1.1.dist-info/RECORD +13 -0
- svc_processing-0.1.1.dist-info/WHEEL +5 -0
- svc_processing-0.1.1.dist-info/entry_points.txt +2 -0
- svc_processing-0.1.1.dist-info/licenses/LICENSE +674 -0
- svc_processing-0.1.1.dist-info/top_level.txt +1 -0
pipeline/run_config.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Run-configuration loading for the SIG processing pipeline.
|
|
2
|
+
|
|
3
|
+
``RunConfig`` encapsulates everything about *what* a run should do: locating and
|
|
4
|
+
parsing the run-config JSON, validating it, expanding input directories,
|
|
5
|
+
applying sensor calibrations, reading the optional ``processing`` block, and
|
|
6
|
+
building the per-directory :class:`PipelineSettings` the orchestration consumes.
|
|
7
|
+
|
|
8
|
+
The orchestration that *uses* these settings lives in ``pipeline/cli.py``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .sig_processor import SigFileProcessor
|
|
20
|
+
|
|
21
|
+
# Reference snapshots of SigFileProcessor's built-in calibration tables, used as
|
|
22
|
+
# the fallback level of `_resolve_calibrations`'s priority order below.
|
|
23
|
+
_BUILTIN_SENSOR_CALIBRATIONS = dict(SigFileProcessor.DEFAULT_CORRECTION_TYPES)
|
|
24
|
+
_BUILTIN_INSTRUMENT_NUMBERS = dict(SigFileProcessor.DEFAULT_INSTRUMENT_NUMBERS)
|
|
25
|
+
|
|
26
|
+
# Parity-verified defaults — deviating from these invalidates the R/spectrolab parity claim.
|
|
27
|
+
_PARITY_DEFAULTS: dict[str, Any] = {
|
|
28
|
+
"band_min": 400,
|
|
29
|
+
"band_max": 2500,
|
|
30
|
+
"resample_fwhm_nm": 10.0,
|
|
31
|
+
"splice_interp_wvl": [5.0, 2.0],
|
|
32
|
+
"fixed_sensor": 2,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_PLACEHOLDER = "<PATH_TO_SIG_INPUT_ROOT>"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_under(base: Path, value: str) -> Path:
|
|
39
|
+
"""Resolve ``value`` against ``base`` unless it is already absolute."""
|
|
40
|
+
path_obj = Path(value).expanduser()
|
|
41
|
+
if path_obj.is_absolute():
|
|
42
|
+
return path_obj
|
|
43
|
+
return base / path_obj
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class PipelineSettings:
|
|
48
|
+
"""Fully-resolved inputs/outputs for a single input directory."""
|
|
49
|
+
|
|
50
|
+
source_name: str
|
|
51
|
+
input_dir: Path
|
|
52
|
+
processed_dir: Path
|
|
53
|
+
resampled_dir: Path
|
|
54
|
+
summary_csv: Path
|
|
55
|
+
merged_csv_name: str
|
|
56
|
+
end_line_overrides: dict[str, str]
|
|
57
|
+
verbose: bool
|
|
58
|
+
processing_params: dict
|
|
59
|
+
correction_types: dict[str, str]
|
|
60
|
+
instrument_numbers: dict[str, str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RunConfig:
|
|
64
|
+
"""A parsed pipeline run configuration and everything derived from it."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
data: dict[str, Any],
|
|
69
|
+
*,
|
|
70
|
+
path: Path,
|
|
71
|
+
base_dir: Path,
|
|
72
|
+
logger: logging.Logger,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.data = data
|
|
75
|
+
self.path = path
|
|
76
|
+
self.base_dir = base_dir
|
|
77
|
+
self._logger = logger
|
|
78
|
+
self._processing_params: dict[str, Any] | None = None
|
|
79
|
+
|
|
80
|
+
# ── construction ─────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def load(cls, base_dir: Path, value: str, logger: logging.Logger) -> "RunConfig":
|
|
84
|
+
"""Resolve ``value`` to a config file, parse it, and return a RunConfig."""
|
|
85
|
+
path = cls._resolve_path(base_dir, value)
|
|
86
|
+
data = cls._read_json(path)
|
|
87
|
+
logger.info("Using config: %s", path)
|
|
88
|
+
return cls(data, path=path, base_dir=base_dir, logger=logger)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _resolve_path(base_dir: Path, value: str) -> Path:
|
|
92
|
+
"""Resolve a run-config argument with friendly fallbacks.
|
|
93
|
+
|
|
94
|
+
Tries, in order: the path as given (relative to ``base_dir``, i.e. the
|
|
95
|
+
current working directory), the same name under ``config/``, and the
|
|
96
|
+
same again with a .json suffix appended. Absolute paths are used as-is.
|
|
97
|
+
"""
|
|
98
|
+
raw = Path(value).expanduser()
|
|
99
|
+
if raw.is_absolute():
|
|
100
|
+
candidates = [raw]
|
|
101
|
+
else:
|
|
102
|
+
names = [raw]
|
|
103
|
+
if raw.suffix == "":
|
|
104
|
+
names.append(raw.with_suffix(".json"))
|
|
105
|
+
candidates = []
|
|
106
|
+
for name in names:
|
|
107
|
+
candidates.append(base_dir / name) # e.g. ./config.json
|
|
108
|
+
candidates.append(base_dir / "config" / name) # e.g. ./config/config.json
|
|
109
|
+
for candidate in candidates:
|
|
110
|
+
if candidate.is_file():
|
|
111
|
+
return candidate
|
|
112
|
+
available = sorted(p.name for p in (base_dir / "config").glob("*.json"))
|
|
113
|
+
raise SystemExit(
|
|
114
|
+
f"Config not found: '{value}'.\n"
|
|
115
|
+
f" Tried: {', '.join(str(c) for c in candidates)}\n"
|
|
116
|
+
f" Available in config/: {', '.join(available) or '(none)'}\n"
|
|
117
|
+
f" Usage: svc-pipeline [CONFIG] [--step ...] [--verbose]"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
122
|
+
try:
|
|
123
|
+
with path.expanduser().open() as handle:
|
|
124
|
+
data = json.load(handle)
|
|
125
|
+
except FileNotFoundError as exc:
|
|
126
|
+
raise SystemExit(f"Config file not found: {path}") from exc
|
|
127
|
+
except json.JSONDecodeError as exc:
|
|
128
|
+
raise SystemExit(f"Config is not valid JSON ({path}): {exc}") from exc
|
|
129
|
+
if not isinstance(data, dict):
|
|
130
|
+
raise SystemExit(f"Config must be a JSON object: {path}")
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
# ── validation ───────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def ensure_no_placeholder(self, input_dir_override: str | None) -> None:
|
|
136
|
+
"""Fail fast if the template's placeholder input path was never edited."""
|
|
137
|
+
if input_dir_override:
|
|
138
|
+
return
|
|
139
|
+
sig_input_dirs = self.data.get("sig_input_dirs") or []
|
|
140
|
+
if isinstance(sig_input_dirs, str):
|
|
141
|
+
sig_input_dirs = [sig_input_dirs]
|
|
142
|
+
raw_inputs = [self.data.get("sig_input_dir"), *sig_input_dirs]
|
|
143
|
+
if any(value == _PLACEHOLDER for value in raw_inputs if value):
|
|
144
|
+
raise SystemExit(
|
|
145
|
+
f'{self.path} still contains the placeholder "{_PLACEHOLDER}".\n'
|
|
146
|
+
f' Edit "sig_input_dir" to point at your .sig data directory, '
|
|
147
|
+
f"or pass --input-dir <path>."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ── derived values ───────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def input_directories(self) -> list[Path]:
|
|
153
|
+
"""Expand the config into the list of input directories to process."""
|
|
154
|
+
explicit_dirs = self.data.get("sig_input_dirs")
|
|
155
|
+
if explicit_dirs:
|
|
156
|
+
if isinstance(explicit_dirs, str):
|
|
157
|
+
candidates = [item.strip() for item in explicit_dirs.split(";") if item.strip()]
|
|
158
|
+
else:
|
|
159
|
+
candidates = list(explicit_dirs)
|
|
160
|
+
return [Path(entry).expanduser() for entry in candidates]
|
|
161
|
+
|
|
162
|
+
base_dir_str = self.data.get("sig_input_dir")
|
|
163
|
+
if not base_dir_str:
|
|
164
|
+
raise ValueError("Configuration must provide 'sig_input_dir' or 'sig_input_dirs'.")
|
|
165
|
+
base_dir = Path(str(base_dir_str)).expanduser()
|
|
166
|
+
|
|
167
|
+
if not bool(self.data.get("process_all_subdirs")):
|
|
168
|
+
return [base_dir]
|
|
169
|
+
if not base_dir.is_dir():
|
|
170
|
+
return [base_dir]
|
|
171
|
+
|
|
172
|
+
subdirs: list[Path] = []
|
|
173
|
+
for child in sorted(base_dir.iterdir()):
|
|
174
|
+
if child.is_dir() and any(gc.suffix.lower() == ".sig" for gc in child.glob("*.sig")):
|
|
175
|
+
subdirs.append(child)
|
|
176
|
+
return subdirs or [base_dir]
|
|
177
|
+
|
|
178
|
+
def processing_params(self) -> dict[str, Any]:
|
|
179
|
+
"""Resolve the optional ``processing`` block (cached).
|
|
180
|
+
|
|
181
|
+
Every key in ``_PARITY_DEFAULTS`` is always present in the result, so
|
|
182
|
+
callers never need their own fallback defaults. The one-time parity
|
|
183
|
+
warning for any non-default value is emitted on first call only.
|
|
184
|
+
"""
|
|
185
|
+
if self._processing_params is None:
|
|
186
|
+
self._processing_params = self._read_processing_params()
|
|
187
|
+
return self._processing_params
|
|
188
|
+
|
|
189
|
+
def _read_processing_params(self) -> dict[str, Any]:
|
|
190
|
+
block = self.data.get("processing") or {}
|
|
191
|
+
params: dict[str, Any] = {}
|
|
192
|
+
for key, default in _PARITY_DEFAULTS.items():
|
|
193
|
+
if key in block:
|
|
194
|
+
value = block[key]
|
|
195
|
+
if isinstance(value, list): # normalise list → tuple for interp_wvl
|
|
196
|
+
value = tuple(value)
|
|
197
|
+
params[key] = value
|
|
198
|
+
default_cmp = tuple(default) if isinstance(default, list) else default
|
|
199
|
+
if value != default_cmp:
|
|
200
|
+
self._logger.warning(
|
|
201
|
+
"processing.%s = %s differs from parity-verified default %s — "
|
|
202
|
+
"R/spectrolab parity is no longer guaranteed.",
|
|
203
|
+
key, value, default,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
params[key] = tuple(default) if isinstance(default, list) else default
|
|
207
|
+
return params
|
|
208
|
+
|
|
209
|
+
def _resolve_calibrations(self, input_dir: Path) -> tuple[dict[str, str], dict[str, str]]:
|
|
210
|
+
"""Resolve the correction-type and instrument-number tables for ``input_dir``.
|
|
211
|
+
|
|
212
|
+
Priority: inline ``instrument`` block > ``sensor_calibration_file`` >
|
|
213
|
+
auto-inferred ``config/calibrations/<input_dir_name>.json`` > built-in
|
|
214
|
+
defaults. Pure — returns plain dicts and never touches
|
|
215
|
+
``SigFileProcessor.DEFAULT_CORRECTION_TYPES``/``DEFAULT_INSTRUMENT_NUMBERS``.
|
|
216
|
+
"""
|
|
217
|
+
inline = self._instrument_block_calibrations()
|
|
218
|
+
if inline is not None:
|
|
219
|
+
return inline
|
|
220
|
+
|
|
221
|
+
instrument_numbers = dict(_BUILTIN_INSTRUMENT_NUMBERS)
|
|
222
|
+
|
|
223
|
+
explicit = self.data.get("sensor_calibration_file")
|
|
224
|
+
if explicit:
|
|
225
|
+
path = _resolve_under(self.base_dir, str(explicit))
|
|
226
|
+
correction_types = SigFileProcessor.parse_correction_types_file(path)
|
|
227
|
+
self._logger.info("Loaded sensor calibration from %s", path.resolve())
|
|
228
|
+
return correction_types, instrument_numbers
|
|
229
|
+
|
|
230
|
+
inferred = self.base_dir / "config" / "calibrations" / f"{input_dir.name}.json"
|
|
231
|
+
if inferred.exists():
|
|
232
|
+
correction_types = SigFileProcessor.parse_correction_types_file(inferred)
|
|
233
|
+
self._logger.info("Loaded sensor calibration from %s", inferred.resolve())
|
|
234
|
+
return correction_types, instrument_numbers
|
|
235
|
+
|
|
236
|
+
self._logger.debug("No sensor calibration file found at %s; using built-in defaults.", inferred)
|
|
237
|
+
return dict(_BUILTIN_SENSOR_CALIBRATIONS), instrument_numbers
|
|
238
|
+
|
|
239
|
+
def _instrument_block_calibrations(self) -> tuple[dict[str, str], dict[str, str]] | None:
|
|
240
|
+
"""Resolve calibrations from the inline ``instrument`` config block, if present."""
|
|
241
|
+
block = self.data.get("instrument")
|
|
242
|
+
if not block:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
end_lines: dict[str, str] = {}
|
|
246
|
+
serials: dict[str, str] = {}
|
|
247
|
+
for sensor_type, values in block.items():
|
|
248
|
+
key = str(sensor_type).strip().lower()
|
|
249
|
+
if isinstance(values, dict):
|
|
250
|
+
if "end_line" in values:
|
|
251
|
+
end_lines[key] = str(values["end_line"]).strip()
|
|
252
|
+
if "serial" in values:
|
|
253
|
+
serials[key] = str(values["serial"]).strip()
|
|
254
|
+
else:
|
|
255
|
+
end_lines[key] = str(values).strip() # flat {"bronze": "2520.4"} shorthand
|
|
256
|
+
|
|
257
|
+
correction_types = {**dict(_BUILTIN_SENSOR_CALIBRATIONS), **end_lines}
|
|
258
|
+
instrument_numbers = {**dict(_BUILTIN_INSTRUMENT_NUMBERS), **serials}
|
|
259
|
+
if end_lines:
|
|
260
|
+
self._logger.info("Instrument end-lines from config: %s", end_lines)
|
|
261
|
+
if serials:
|
|
262
|
+
self._logger.info("Instrument serials from config: %s", serials)
|
|
263
|
+
return correction_types, instrument_numbers
|
|
264
|
+
|
|
265
|
+
def settings_for(self, input_dir: Path, *, verbose: bool) -> PipelineSettings:
|
|
266
|
+
"""Build the fully-resolved :class:`PipelineSettings` for one input directory."""
|
|
267
|
+
input_dir = Path(input_dir).expanduser()
|
|
268
|
+
source_name = input_dir.name or "sig_input"
|
|
269
|
+
correction_types, instrument_numbers = self._resolve_calibrations(input_dir)
|
|
270
|
+
|
|
271
|
+
output_root: Path | None = None
|
|
272
|
+
if self.data.get("output_dir"):
|
|
273
|
+
output_root = _resolve_under(self.base_dir, str(self.data["output_dir"]))
|
|
274
|
+
base_output = output_root or self.base_dir
|
|
275
|
+
|
|
276
|
+
processed_root = _resolve_under(base_output, str(self.data["processed_dir"]))
|
|
277
|
+
resampled_root = _resolve_under(base_output, str(self.data["resampled_dir"]))
|
|
278
|
+
|
|
279
|
+
processed_dir = processed_root / source_name
|
|
280
|
+
resampled_dir = resampled_root / source_name
|
|
281
|
+
summary_csv = processed_dir / f"{source_name}_{self.data['summary_csv_name']}"
|
|
282
|
+
merged_csv_name = f"{source_name}_{self.data['merged_csv_name']}"
|
|
283
|
+
|
|
284
|
+
overrides_raw = self.data.get("end_line_overrides") or {}
|
|
285
|
+
end_line_overrides = {
|
|
286
|
+
str(key).strip().lower(): str(value).strip()
|
|
287
|
+
for key, value in overrides_raw.items()
|
|
288
|
+
if key is not None
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return PipelineSettings(
|
|
292
|
+
source_name=source_name,
|
|
293
|
+
input_dir=input_dir,
|
|
294
|
+
processed_dir=processed_dir,
|
|
295
|
+
resampled_dir=resampled_dir,
|
|
296
|
+
summary_csv=summary_csv,
|
|
297
|
+
merged_csv_name=merged_csv_name,
|
|
298
|
+
end_line_overrides=end_line_overrides,
|
|
299
|
+
verbose=verbose,
|
|
300
|
+
processing_params=self.processing_params(),
|
|
301
|
+
correction_types=correction_types,
|
|
302
|
+
instrument_numbers=instrument_numbers,
|
|
303
|
+
)
|
pipeline/runner.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Execution of the two SIG processing stages.
|
|
2
|
+
|
|
3
|
+
:class:`Pipeline` runs, for a single resolved
|
|
4
|
+
:class:`~pipeline.run_config.PipelineSettings`:
|
|
5
|
+
|
|
6
|
+
* Stage 1 (:meth:`Pipeline.process_sig_files`) — truncate raw ``.sig`` files at
|
|
7
|
+
the calibration end-line and write a per-run summary CSV.
|
|
8
|
+
* Stage 2 (:meth:`Pipeline.resample`) — run the pure-Python resampler over the
|
|
9
|
+
processed files.
|
|
10
|
+
|
|
11
|
+
Config loading that produces the ``PipelineSettings`` lives in
|
|
12
|
+
``pipeline/run_config.py``; the CLI that wires them together lives in
|
|
13
|
+
``pipeline/cli.py``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import csv
|
|
19
|
+
import logging
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Iterable
|
|
22
|
+
|
|
23
|
+
from .resampler import resample_spectra
|
|
24
|
+
from .run_config import PipelineSettings
|
|
25
|
+
from .sig_processor import SigFileProcessor
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Pipeline:
|
|
29
|
+
"""Runs the processing + resampling stages for one input directory."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, settings: PipelineSettings, logger: logging.Logger) -> None:
|
|
32
|
+
self.settings = settings
|
|
33
|
+
self.logger = logger
|
|
34
|
+
|
|
35
|
+
# ── public entry point ───────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def run(self, step: str) -> dict[str, Path | None]:
|
|
38
|
+
"""Run the requested step(s) and report the produced artifacts.
|
|
39
|
+
|
|
40
|
+
``step``: ``"1"`` = Stage 1 only, ``"2"`` = Stage 2 only (Stage 1 must
|
|
41
|
+
have run previously), ``"all"`` = both.
|
|
42
|
+
"""
|
|
43
|
+
summary_csv: Path | None
|
|
44
|
+
if step in {"1", "all"}:
|
|
45
|
+
summary_csv = self.process_sig_files()
|
|
46
|
+
else:
|
|
47
|
+
summary_csv = self.settings.summary_csv if self.settings.summary_csv.exists() else None
|
|
48
|
+
if summary_csv is None:
|
|
49
|
+
self.logger.error(
|
|
50
|
+
"Summary CSV not found at %s (run --step 1 first).",
|
|
51
|
+
self.settings.summary_csv.resolve(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
merged_csv: Path | None = None
|
|
55
|
+
if step in {"2", "all"}:
|
|
56
|
+
merged_csv = self.resample(summary_csv)
|
|
57
|
+
|
|
58
|
+
return {"summary_csv": summary_csv, "merged_csv": merged_csv}
|
|
59
|
+
|
|
60
|
+
# ── Stage 1: process raw .sig files ──────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def process_sig_files(self) -> Path | None:
|
|
63
|
+
settings = self.settings
|
|
64
|
+
logger = self.logger
|
|
65
|
+
input_dir = settings.input_dir
|
|
66
|
+
output_dir = settings.processed_dir
|
|
67
|
+
summary_csv = settings.summary_csv
|
|
68
|
+
verbose = settings.verbose
|
|
69
|
+
|
|
70
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if not input_dir.exists():
|
|
72
|
+
logger.error("Input directory missing: %s", input_dir.resolve())
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
if verbose:
|
|
76
|
+
logger.info("Starting SIG processing: %s -> %s", input_dir.resolve(), output_dir.resolve())
|
|
77
|
+
logger.info("Checking instrument consistency")
|
|
78
|
+
|
|
79
|
+
inspection_processor = SigFileProcessor(
|
|
80
|
+
correction_type="silver",
|
|
81
|
+
correction_types=settings.correction_types,
|
|
82
|
+
instrument_numbers=settings.instrument_numbers,
|
|
83
|
+
logger=logger,
|
|
84
|
+
)
|
|
85
|
+
consistency = inspection_processor.check_instrument_consistency(str(input_dir))
|
|
86
|
+
|
|
87
|
+
for warning in consistency.get("warnings", []):
|
|
88
|
+
logger.warning("%s", warning)
|
|
89
|
+
|
|
90
|
+
if consistency.get("total_files", 0) == 0:
|
|
91
|
+
logger.warning("No SIG files found in %s", input_dir.resolve())
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
if not consistency.get("consistent", False):
|
|
95
|
+
logger.error("Instrument mismatch detected; aborting processing.")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
instrument_name = str(consistency.get("instrument_name") or "")
|
|
99
|
+
instrument_value = str(consistency.get("instrument") or "")
|
|
100
|
+
sensor_type = instrument_name.lower()
|
|
101
|
+
|
|
102
|
+
end_line_value = settings.end_line_overrides.get(sensor_type) or settings.correction_types.get(sensor_type)
|
|
103
|
+
if not end_line_value:
|
|
104
|
+
logger.error("No end-line value available for sensor type '%s'", sensor_type)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
if verbose:
|
|
108
|
+
logger.info("Instrument verified: %s (%s)", instrument_name, instrument_value)
|
|
109
|
+
logger.info("Clearing previous processed SIG outputs")
|
|
110
|
+
|
|
111
|
+
for path in output_dir.glob("*.sig"):
|
|
112
|
+
try:
|
|
113
|
+
path.unlink()
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
logger.warning("Could not remove %s: %s", path, exc)
|
|
116
|
+
|
|
117
|
+
if verbose:
|
|
118
|
+
logger.info("Running SigFileProcessor (end line %s)", end_line_value)
|
|
119
|
+
|
|
120
|
+
processor = SigFileProcessor(correction_value=end_line_value, logger=logger)
|
|
121
|
+
processor.process_sig_files(
|
|
122
|
+
input_folder=str(input_dir),
|
|
123
|
+
output_folder=str(output_dir),
|
|
124
|
+
verbose=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if verbose:
|
|
128
|
+
logger.info("Building processed SIG summary")
|
|
129
|
+
|
|
130
|
+
rows = list(
|
|
131
|
+
self._collect_summary_rows(
|
|
132
|
+
input_dir,
|
|
133
|
+
output_dir,
|
|
134
|
+
instrument_value,
|
|
135
|
+
instrument_name,
|
|
136
|
+
sensor_type,
|
|
137
|
+
end_line_value,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
if not rows:
|
|
141
|
+
logger.warning("No processed SIG files were produced.")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
summary_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
|
|
146
|
+
if verbose:
|
|
147
|
+
logger.info("Writing summary CSV to %s", summary_csv.resolve())
|
|
148
|
+
|
|
149
|
+
with summary_csv.open("w", newline="") as csvfile:
|
|
150
|
+
writer = csv.DictWriter(csvfile, fieldnames=list(rows[0].keys()))
|
|
151
|
+
writer.writeheader()
|
|
152
|
+
writer.writerows(rows)
|
|
153
|
+
|
|
154
|
+
logger.info("Processed SIG summary written to %s", summary_csv.resolve())
|
|
155
|
+
if verbose:
|
|
156
|
+
logger.info("Completed SIG processing")
|
|
157
|
+
return summary_csv
|
|
158
|
+
|
|
159
|
+
# ── Stage 2: resample ────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def resample(self, summary_csv: Path | None) -> Path | None:
|
|
162
|
+
settings = self.settings
|
|
163
|
+
logger = self.logger
|
|
164
|
+
if summary_csv is None:
|
|
165
|
+
logger.error("Skipping resampling because SIG processing did not produce a summary.")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
input_dir = settings.processed_dir
|
|
169
|
+
output_dir = settings.resampled_dir
|
|
170
|
+
output_file = settings.merged_csv_name
|
|
171
|
+
verbose = settings.verbose
|
|
172
|
+
|
|
173
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
if verbose:
|
|
176
|
+
logger.info("Starting Python resampler on %s", input_dir.resolve())
|
|
177
|
+
|
|
178
|
+
logger.info("Running Python resampler: %s -> %s/%s", input_dir, output_dir, output_file)
|
|
179
|
+
# Every key is guaranteed present by RunConfig.processing_params(); no fallbacks needed.
|
|
180
|
+
params = settings.processing_params
|
|
181
|
+
merged_path = resample_spectra(
|
|
182
|
+
input_dir, output_dir, output_file,
|
|
183
|
+
band_min=params["band_min"],
|
|
184
|
+
band_max=params["band_max"],
|
|
185
|
+
fwhm_nm=params["resample_fwhm_nm"],
|
|
186
|
+
fixed_sensor=params["fixed_sensor"],
|
|
187
|
+
interp_wvl=params["splice_interp_wvl"],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if merged_path.exists():
|
|
191
|
+
logger.info("Merged spectra available at %s", merged_path.resolve())
|
|
192
|
+
return merged_path
|
|
193
|
+
|
|
194
|
+
logger.warning("Merged spectra file was not created at %s", merged_path)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _collect_summary_rows(
|
|
201
|
+
input_dir: Path,
|
|
202
|
+
output_dir: Path,
|
|
203
|
+
instrument_value: str,
|
|
204
|
+
instrument_name: str,
|
|
205
|
+
sensor_type: str,
|
|
206
|
+
end_line_value: str,
|
|
207
|
+
) -> Iterable[dict[str, str]]:
|
|
208
|
+
for output_path in sorted(output_dir.glob("*.sig")):
|
|
209
|
+
yield {
|
|
210
|
+
"input_file": str(input_dir / output_path.name),
|
|
211
|
+
"processed_file": str(output_path),
|
|
212
|
+
"instrument_value": instrument_value,
|
|
213
|
+
"instrument_name": instrument_name,
|
|
214
|
+
"correction_type": sensor_type,
|
|
215
|
+
"end_line_value": end_line_value,
|
|
216
|
+
}
|