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/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
+ }