svc-processing 0.1.1__tar.gz → 0.1.3__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.
Files changed (28) hide show
  1. {svc_processing-0.1.1 → svc_processing-0.1.3}/PKG-INFO +25 -6
  2. {svc_processing-0.1.1 → svc_processing-0.1.3}/README.md +24 -5
  3. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/cli.py +45 -4
  4. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/run_config.py +56 -1
  5. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/runner.py +58 -5
  6. {svc_processing-0.1.1 → svc_processing-0.1.3}/pyproject.toml +1 -1
  7. {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/PKG-INFO +25 -6
  8. svc_processing-0.1.3/tests/test_cli.py +184 -0
  9. {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_run_config.py +76 -0
  10. svc_processing-0.1.3/tests/test_runner.py +136 -0
  11. svc_processing-0.1.1/tests/test_cli.py +0 -85
  12. svc_processing-0.1.1/tests/test_runner.py +0 -53
  13. {svc_processing-0.1.1 → svc_processing-0.1.3}/LICENSE +0 -0
  14. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/__init__.py +0 -0
  15. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/processor.py +0 -0
  16. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/resampler.py +0 -0
  17. {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/sig_processor.py +0 -0
  18. {svc_processing-0.1.1 → svc_processing-0.1.3}/setup.cfg +0 -0
  19. {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/SOURCES.txt +0 -0
  20. {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/dependency_links.txt +0 -0
  21. {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/entry_points.txt +0 -0
  22. {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/requires.txt +0 -0
  23. {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/top_level.txt +0 -0
  24. {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_pipeline_demo.py +0 -0
  25. {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_processor.py +0 -0
  26. {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_resampler_parity.py +0 -0
  27. {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_resampler_public_api.py +0 -0
  28. {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_sig_processor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: svc-processing
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Pure-Python SVC HR-1024i hyperspectral .sig processing pipeline
5
5
  Author: Gold Lab (GrapeSPEC project), Cornell University
6
6
  Author-email: Cole Regnier <nr466@cornell.edu>
@@ -65,6 +65,20 @@ Generated outputs (gitignored): `pipeline_outputs/sig_processed/<run>/`, `pipeli
65
65
  > New to the pipeline? [`TUTORIAL.md`](TUTORIAL.md) starts with the interactive
66
66
  > Jupyter notebook (the gentle path) and has an advanced track for the CLI below.
67
67
 
68
+ **Installed from PyPI, in a new project folder** (no clone of this repo needed):
69
+
70
+ ```bash
71
+ python3.11 -m venv .venv
72
+ source .venv/bin/activate
73
+ python -m pip install svc-processing # package name
74
+
75
+ svc-pipeline --init-config # command name — different from the package name above
76
+ # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
77
+ svc-pipeline
78
+ ```
79
+
80
+ **Working from a clone of this repo** (config/config.json already exists):
81
+
68
82
  ```bash
69
83
  python3.11 -m venv .venv
70
84
  source .venv/bin/activate
@@ -112,8 +126,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
112
126
  └────────────────────────────────────┘
113
127
  ```
114
128
 
115
- The `svc-pipeline` console script glues Stages 1 and 2 together through
116
- `pipeline.cli`; Stage 3 is invoked from notebooks against the Stage 2 output.
129
+ The `svc-pipeline` console script runs Stages 1 and 2 through `pipeline.cli`,
130
+ and Stage 3 too once a `groups_csv` is configured (or passed via
131
+ `--groups-csv`) — see [`config/README.md`](config/README.md#grouping--stage-3-optional).
132
+ Stage 3 is also available directly from notebooks against the Stage 2 output.
117
133
  The pipeline is **single-pass and idempotent per input directory** — previous
118
134
  processed `.sig` files in the target directory are deleted at the start of each
119
135
  run.
@@ -132,7 +148,9 @@ svc-pipeline [config] [options]
132
148
  |---|---|
133
149
  | `config` | Run-config JSON (positional, optional; default `config/config.json`). Bare names resolve under `config/`, so `config.json`, `config`, and `config/config.json` are equivalent. See [`config/README.md`](config/README.md) for schema. |
134
150
  | `--input-dir <path>` | Override `sig_input_dir` and process only this directory. |
135
- | `--step {1,2,all}` | `1` = process + summary CSV only; `2` = resample only (requires Stage 1 to have been run); `all` = both (default). |
151
+ | `--step {1,2,3,all}` | `1` = process + summary CSV only; `2` = resample only (requires Stage 1); `3` = group + average only (requires Stage 2 and `groups_csv`); `all` = every stage that's configured (default). |
152
+ | `--groups-csv <path>` | Override the config's `groups_csv` to group repeat scans and average them. |
153
+ | `--group-method {mean,median,sum,min,max}` | Override the config's `group_agg_method` for Stage 3 (default `mean`). |
136
154
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
137
155
 
138
156
  ### Expected output layout
@@ -143,10 +161,11 @@ pipeline_outputs/
143
161
  │ ├── <truncated *.sig files>
144
162
  │ └── <input_dir_name>_processed_sig_summary.csv
145
163
  └── sig_resampled/<input_dir_name>/
146
- └── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
164
+ ├── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
165
+ └── <input_dir_name>_grouped_spectra.csv # only if groups_csv is configured
147
166
  ```
148
167
 
149
- `summary_csv_name` and `merged_csv_name` in the run config are suffixes; the real filenames are prefixed with the input directory name. Verify your config in [`config/README.md`](config/README.md).
168
+ `summary_csv_name`, `merged_csv_name`, and `grouped_csv_name` in the run config are suffixes; the real filenames are prefixed with the input directory name. Verify your config in [`config/README.md`](config/README.md).
150
169
 
151
170
  ---
152
171
 
@@ -32,6 +32,20 @@ Generated outputs (gitignored): `pipeline_outputs/sig_processed/<run>/`, `pipeli
32
32
  > New to the pipeline? [`TUTORIAL.md`](TUTORIAL.md) starts with the interactive
33
33
  > Jupyter notebook (the gentle path) and has an advanced track for the CLI below.
34
34
 
35
+ **Installed from PyPI, in a new project folder** (no clone of this repo needed):
36
+
37
+ ```bash
38
+ python3.11 -m venv .venv
39
+ source .venv/bin/activate
40
+ python -m pip install svc-processing # package name
41
+
42
+ svc-pipeline --init-config # command name — different from the package name above
43
+ # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
44
+ svc-pipeline
45
+ ```
46
+
47
+ **Working from a clone of this repo** (config/config.json already exists):
48
+
35
49
  ```bash
36
50
  python3.11 -m venv .venv
37
51
  source .venv/bin/activate
@@ -79,8 +93,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
79
93
  └────────────────────────────────────┘
80
94
  ```
81
95
 
82
- The `svc-pipeline` console script glues Stages 1 and 2 together through
83
- `pipeline.cli`; Stage 3 is invoked from notebooks against the Stage 2 output.
96
+ The `svc-pipeline` console script runs Stages 1 and 2 through `pipeline.cli`,
97
+ and Stage 3 too once a `groups_csv` is configured (or passed via
98
+ `--groups-csv`) — see [`config/README.md`](config/README.md#grouping--stage-3-optional).
99
+ Stage 3 is also available directly from notebooks against the Stage 2 output.
84
100
  The pipeline is **single-pass and idempotent per input directory** — previous
85
101
  processed `.sig` files in the target directory are deleted at the start of each
86
102
  run.
@@ -99,7 +115,9 @@ svc-pipeline [config] [options]
99
115
  |---|---|
100
116
  | `config` | Run-config JSON (positional, optional; default `config/config.json`). Bare names resolve under `config/`, so `config.json`, `config`, and `config/config.json` are equivalent. See [`config/README.md`](config/README.md) for schema. |
101
117
  | `--input-dir <path>` | Override `sig_input_dir` and process only this directory. |
102
- | `--step {1,2,all}` | `1` = process + summary CSV only; `2` = resample only (requires Stage 1 to have been run); `all` = both (default). |
118
+ | `--step {1,2,3,all}` | `1` = process + summary CSV only; `2` = resample only (requires Stage 1); `3` = group + average only (requires Stage 2 and `groups_csv`); `all` = every stage that's configured (default). |
119
+ | `--groups-csv <path>` | Override the config's `groups_csv` to group repeat scans and average them. |
120
+ | `--group-method {mean,median,sum,min,max}` | Override the config's `group_agg_method` for Stage 3 (default `mean`). |
103
121
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
104
122
 
105
123
  ### Expected output layout
@@ -110,10 +128,11 @@ pipeline_outputs/
110
128
  │ ├── <truncated *.sig files>
111
129
  │ └── <input_dir_name>_processed_sig_summary.csv
112
130
  └── sig_resampled/<input_dir_name>/
113
- └── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
131
+ ├── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
132
+ └── <input_dir_name>_grouped_spectra.csv # only if groups_csv is configured
114
133
  ```
115
134
 
116
- `summary_csv_name` and `merged_csv_name` in the run config are suffixes; the real filenames are prefixed with the input directory name. Verify your config in [`config/README.md`](config/README.md).
135
+ `summary_csv_name`, `merged_csv_name`, and `grouped_csv_name` in the run config are suffixes; the real filenames are prefixed with the input directory name. Verify your config in [`config/README.md`](config/README.md).
117
136
 
118
137
  ---
119
138
 
@@ -28,7 +28,13 @@ def _configure_logging(verbose: bool) -> logging.Logger:
28
28
 
29
29
 
30
30
  def _parse_args() -> argparse.Namespace:
31
- parser = argparse.ArgumentParser(description="Run the SIG processing pipeline")
31
+ parser = argparse.ArgumentParser(
32
+ description="Run the SIG processing pipeline",
33
+ epilog=(
34
+ "Full README, TUTORIAL, and config reference: "
35
+ "https://github.com/regs08/SVCProcessingPipeline"
36
+ ),
37
+ )
32
38
  parser.add_argument(
33
39
  "config",
34
40
  nargs="?",
@@ -41,15 +47,36 @@ def _parse_args() -> argparse.Namespace:
41
47
  )
42
48
  parser.add_argument(
43
49
  "--step",
44
- choices=["1", "2", "all"],
50
+ choices=["1", "2", "3", "all"],
45
51
  default="all",
46
- help="Which step to run: 1=process+summary CSV, 2=Python resampling only, all=run both steps.",
52
+ help=(
53
+ "Which step to run: 1=process+summary CSV, 2=Python resampling only, "
54
+ "3=group+average only (requires groups_csv; step 2 must have already run), "
55
+ "all=every step that's configured (step 3 only if groups_csv is set)."
56
+ ),
47
57
  )
48
58
  parser.add_argument(
49
59
  "--verbose",
50
60
  action="store_true",
51
61
  help="Enable verbose logging before and after each processing step",
52
62
  )
63
+ parser.add_argument(
64
+ "--init-config",
65
+ action="store_true",
66
+ help="Write a starter config/config.json in the current directory and exit.",
67
+ )
68
+ parser.add_argument(
69
+ "--groups-csv",
70
+ help=(
71
+ "Override the config's groups_csv: group repeat scans of the same sample "
72
+ "and average them (a CSV with scans/scan_id + name columns)."
73
+ ),
74
+ )
75
+ parser.add_argument(
76
+ "--group-method",
77
+ choices=["mean", "median", "sum", "min", "max"],
78
+ help="Override the config's group_agg_method for step 3 (default: mean).",
79
+ )
53
80
  return parser.parse_args()
54
81
 
55
82
 
@@ -61,6 +88,13 @@ def main() -> None:
61
88
  # working directory so the installed `svc-pipeline` command works from any
62
89
  # directory. Absolute paths in the config are always honoured as-is.
63
90
  base_dir = Path.cwd()
91
+
92
+ if args.init_config:
93
+ target = RunConfig.write_starter_config(base_dir)
94
+ print(f"Wrote starter config: {target.resolve()}")
95
+ print('Edit "sig_input_dir" to point at your .sig data, then run: svc-pipeline')
96
+ return
97
+
64
98
  run_config = RunConfig.load(base_dir, str(args.config), logger)
65
99
  run_config.ensure_no_placeholder(args.input_dir)
66
100
 
@@ -73,9 +107,16 @@ def main() -> None:
73
107
  # warning for any non-default value before the per-directory work begins.
74
108
  run_config.processing_params()
75
109
 
110
+ groups_csv_override = Path(args.groups_csv).expanduser() if args.groups_csv else None
111
+
76
112
  overall_results: list[tuple[Path, dict[str, Path | None]]] = []
77
113
  for input_dir in input_dirs:
78
- settings = run_config.settings_for(input_dir, verbose=args.verbose)
114
+ settings = run_config.settings_for(
115
+ input_dir,
116
+ verbose=args.verbose,
117
+ groups_csv_override=groups_csv_override,
118
+ group_method_override=args.group_method,
119
+ )
79
120
  results = Pipeline(settings, logger).run(args.step)
80
121
  overall_results.append((input_dir, results))
81
122
 
@@ -34,6 +34,24 @@ _PARITY_DEFAULTS: dict[str, Any] = {
34
34
 
35
35
  _PLACEHOLDER = "<PATH_TO_SIG_INPUT_ROOT>"
36
36
 
37
+ # Starter template written by `svc-pipeline --init-config`. Mirrors the
38
+ # repo's own config/config.json — keep the two in sync if either changes.
39
+ _STARTER_CONFIG_TEMPLATE: dict[str, Any] = {
40
+ "sig_input_dir": _PLACEHOLDER,
41
+ "process_all_subdirs": True,
42
+ "processed_dir": "sig_processed",
43
+ "resampled_dir": "sig_resampled",
44
+ "output_dir": "pipeline_outputs",
45
+ "summary_csv_name": "processed_sig_summary.csv",
46
+ "merged_csv_name": "merged_spectra.csv",
47
+ "end_line_overrides": {},
48
+ "instrument": {
49
+ "bronze": {"end_line": "2520.4", "serial": "2212118"},
50
+ "silver": {"end_line": "2517.9", "serial": "1202103"},
51
+ },
52
+ "processing": dict(_PARITY_DEFAULTS),
53
+ }
54
+
37
55
 
38
56
  def _resolve_under(base: Path, value: str) -> Path:
39
57
  """Resolve ``value`` against ``base`` unless it is already absolute."""
@@ -58,6 +76,9 @@ class PipelineSettings:
58
76
  processing_params: dict
59
77
  correction_types: dict[str, str]
60
78
  instrument_numbers: dict[str, str]
79
+ groups_csv: Path | None
80
+ group_agg_method: str
81
+ grouped_csv_name: str
61
82
 
62
83
 
63
84
  class RunConfig:
@@ -87,6 +108,25 @@ class RunConfig:
87
108
  logger.info("Using config: %s", path)
88
109
  return cls(data, path=path, base_dir=base_dir, logger=logger)
89
110
 
111
+ @classmethod
112
+ def write_starter_config(cls, base_dir: Path, *, filename: str = "config.json") -> Path:
113
+ """Write a starter ``config/<filename>`` under ``base_dir`` and return its path.
114
+
115
+ Refuses to overwrite an existing file, so re-running this never clobbers
116
+ edits you've already made.
117
+ """
118
+ target = base_dir / "config" / filename
119
+ if target.exists():
120
+ raise SystemExit(
121
+ f"{target} already exists — not overwriting.\n"
122
+ f" Edit it directly, or remove it first to regenerate the starter template."
123
+ )
124
+ target.parent.mkdir(parents=True, exist_ok=True)
125
+ with target.open("w") as handle:
126
+ json.dump(_STARTER_CONFIG_TEMPLATE, handle, indent=2)
127
+ handle.write("\n")
128
+ return target
129
+
90
130
  @staticmethod
91
131
  def _resolve_path(base_dir: Path, value: str) -> Path:
92
132
  """Resolve a run-config argument with friendly fallbacks.
@@ -262,7 +302,14 @@ class RunConfig:
262
302
  self._logger.info("Instrument serials from config: %s", serials)
263
303
  return correction_types, instrument_numbers
264
304
 
265
- def settings_for(self, input_dir: Path, *, verbose: bool) -> PipelineSettings:
305
+ def settings_for(
306
+ self,
307
+ input_dir: Path,
308
+ *,
309
+ verbose: bool,
310
+ groups_csv_override: str | Path | None = None,
311
+ group_method_override: str | None = None,
312
+ ) -> PipelineSettings:
266
313
  """Build the fully-resolved :class:`PipelineSettings` for one input directory."""
267
314
  input_dir = Path(input_dir).expanduser()
268
315
  source_name = input_dir.name or "sig_input"
@@ -288,6 +335,11 @@ class RunConfig:
288
335
  if key is not None
289
336
  }
290
337
 
338
+ groups_csv_value = groups_csv_override or self.data.get("groups_csv")
339
+ groups_csv = _resolve_under(self.base_dir, str(groups_csv_value)) if groups_csv_value else None
340
+ group_agg_method = str(group_method_override or self.data.get("group_agg_method", "mean"))
341
+ grouped_csv_name = f"{source_name}_{self.data.get('grouped_csv_name', 'grouped_spectra.csv')}"
342
+
291
343
  return PipelineSettings(
292
344
  source_name=source_name,
293
345
  input_dir=input_dir,
@@ -300,4 +352,7 @@ class RunConfig:
300
352
  processing_params=self.processing_params(),
301
353
  correction_types=correction_types,
302
354
  instrument_numbers=instrument_numbers,
355
+ groups_csv=groups_csv,
356
+ group_agg_method=group_agg_method,
357
+ grouped_csv_name=grouped_csv_name,
303
358
  )
@@ -1,4 +1,4 @@
1
- """Execution of the two SIG processing stages.
1
+ """Execution of the three SIG processing stages.
2
2
 
3
3
  :class:`Pipeline` runs, for a single resolved
4
4
  :class:`~pipeline.run_config.PipelineSettings`:
@@ -7,6 +7,9 @@
7
7
  the calibration end-line and write a per-run summary CSV.
8
8
  * Stage 2 (:meth:`Pipeline.resample`) — run the pure-Python resampler over the
9
9
  processed files.
10
+ * Stage 3 (:meth:`Pipeline.group_and_average`) — optional; only runs when the
11
+ config supplies a ``groups_csv``. Groups repeat scans of the same sample and
12
+ averages them into one spectrum per group.
10
13
 
11
14
  Config loading that produces the ``PipelineSettings`` lives in
12
15
  ``pipeline/run_config.py``; the CLI that wires them together lives in
@@ -20,6 +23,7 @@ import logging
20
23
  from pathlib import Path
21
24
  from typing import Iterable
22
25
 
26
+ from .processor import GroupSpec, SigSpectraAverager
23
27
  from .resampler import resample_spectra
24
28
  from .run_config import PipelineSettings
25
29
  from .sig_processor import SigFileProcessor
@@ -38,12 +42,14 @@ class Pipeline:
38
42
  """Run the requested step(s) and report the produced artifacts.
39
43
 
40
44
  ``step``: ``"1"`` = Stage 1 only, ``"2"`` = Stage 2 only (Stage 1 must
41
- have run previously), ``"all"`` = both.
45
+ have run previously), ``"3"`` = Stage 3 only (Stage 2 must have run
46
+ previously; requires ``groups_csv`` to be configured), ``"all"`` = every
47
+ stage that's configured to run (Stage 3 only if ``groups_csv`` is set).
42
48
  """
43
- summary_csv: Path | None
49
+ summary_csv: Path | None = None
44
50
  if step in {"1", "all"}:
45
51
  summary_csv = self.process_sig_files()
46
- else:
52
+ elif step == "2":
47
53
  summary_csv = self.settings.summary_csv if self.settings.summary_csv.exists() else None
48
54
  if summary_csv is None:
49
55
  self.logger.error(
@@ -54,8 +60,17 @@ class Pipeline:
54
60
  merged_csv: Path | None = None
55
61
  if step in {"2", "all"}:
56
62
  merged_csv = self.resample(summary_csv)
63
+ elif step == "3":
64
+ merged_path = self.settings.resampled_dir / self.settings.merged_csv_name
65
+ merged_csv = merged_path if merged_path.exists() else None
66
+ if merged_csv is None:
67
+ self.logger.error("Merged CSV not found at %s (run --step 2 first).", merged_path.resolve())
57
68
 
58
- return {"summary_csv": summary_csv, "merged_csv": merged_csv}
69
+ grouped_csv: Path | None = None
70
+ if step in {"3", "all"} and self.settings.groups_csv is not None:
71
+ grouped_csv = self.group_and_average(merged_csv)
72
+
73
+ return {"summary_csv": summary_csv, "merged_csv": merged_csv, "grouped_csv": grouped_csv}
59
74
 
60
75
  # ── Stage 1: process raw .sig files ──────────────────────────────────────
61
76
 
@@ -194,6 +209,44 @@ class Pipeline:
194
209
  logger.warning("Merged spectra file was not created at %s", merged_path)
195
210
  return None
196
211
 
212
+ # ── Stage 3: group & average (optional) ──────────────────────────────────
213
+
214
+ def group_and_average(self, merged_csv: Path | None) -> Path | None:
215
+ """Average repeat scans of the same sample into one spectrum per group.
216
+
217
+ Requires ``settings.groups_csv`` (checked by the caller) and a merged
218
+ CSV from Stage 2. Groups are read via :meth:`GroupSpec.from_csv`
219
+ (``scans``/``scan_id`` + ``name`` columns) and applied with
220
+ :class:`SigSpectraAverager`.
221
+ """
222
+ settings = self.settings
223
+ logger = self.logger
224
+ if merged_csv is None:
225
+ logger.error("Skipping grouping because no merged CSV is available.")
226
+ return None
227
+
228
+ groups_csv = settings.groups_csv
229
+ if groups_csv is None or not groups_csv.exists():
230
+ logger.error("Groups CSV not found: %s", groups_csv.resolve() if groups_csv else groups_csv)
231
+ return None
232
+
233
+ groups = GroupSpec.from_csv(groups_csv)
234
+ if not groups:
235
+ logger.warning("No groups found in %s; nothing to average.", groups_csv.resolve())
236
+ return None
237
+
238
+ if settings.verbose:
239
+ logger.info("Grouping %s using %s (%s)", merged_csv, groups_csv.resolve(), settings.group_agg_method)
240
+
241
+ averager = SigSpectraAverager.from_csv(merged_csv)
242
+ grouped_df = averager.aggregate(groups, method=settings.group_agg_method)
243
+
244
+ output_path = settings.resampled_dir / settings.grouped_csv_name
245
+ output_path.parent.mkdir(parents=True, exist_ok=True)
246
+ grouped_df.to_csv(output_path, index=False)
247
+ logger.info("Grouped/averaged spectra written to %s", output_path.resolve())
248
+ return output_path
249
+
197
250
  # ── helpers ──────────────────────────────────────────────────────────────
198
251
 
199
252
  @staticmethod
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "svc-processing"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Pure-Python SVC HR-1024i hyperspectral .sig processing pipeline"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: svc-processing
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Pure-Python SVC HR-1024i hyperspectral .sig processing pipeline
5
5
  Author: Gold Lab (GrapeSPEC project), Cornell University
6
6
  Author-email: Cole Regnier <nr466@cornell.edu>
@@ -65,6 +65,20 @@ Generated outputs (gitignored): `pipeline_outputs/sig_processed/<run>/`, `pipeli
65
65
  > New to the pipeline? [`TUTORIAL.md`](TUTORIAL.md) starts with the interactive
66
66
  > Jupyter notebook (the gentle path) and has an advanced track for the CLI below.
67
67
 
68
+ **Installed from PyPI, in a new project folder** (no clone of this repo needed):
69
+
70
+ ```bash
71
+ python3.11 -m venv .venv
72
+ source .venv/bin/activate
73
+ python -m pip install svc-processing # package name
74
+
75
+ svc-pipeline --init-config # command name — different from the package name above
76
+ # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
77
+ svc-pipeline
78
+ ```
79
+
80
+ **Working from a clone of this repo** (config/config.json already exists):
81
+
68
82
  ```bash
69
83
  python3.11 -m venv .venv
70
84
  source .venv/bin/activate
@@ -112,8 +126,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
112
126
  └────────────────────────────────────┘
113
127
  ```
114
128
 
115
- The `svc-pipeline` console script glues Stages 1 and 2 together through
116
- `pipeline.cli`; Stage 3 is invoked from notebooks against the Stage 2 output.
129
+ The `svc-pipeline` console script runs Stages 1 and 2 through `pipeline.cli`,
130
+ and Stage 3 too once a `groups_csv` is configured (or passed via
131
+ `--groups-csv`) — see [`config/README.md`](config/README.md#grouping--stage-3-optional).
132
+ Stage 3 is also available directly from notebooks against the Stage 2 output.
117
133
  The pipeline is **single-pass and idempotent per input directory** — previous
118
134
  processed `.sig` files in the target directory are deleted at the start of each
119
135
  run.
@@ -132,7 +148,9 @@ svc-pipeline [config] [options]
132
148
  |---|---|
133
149
  | `config` | Run-config JSON (positional, optional; default `config/config.json`). Bare names resolve under `config/`, so `config.json`, `config`, and `config/config.json` are equivalent. See [`config/README.md`](config/README.md) for schema. |
134
150
  | `--input-dir <path>` | Override `sig_input_dir` and process only this directory. |
135
- | `--step {1,2,all}` | `1` = process + summary CSV only; `2` = resample only (requires Stage 1 to have been run); `all` = both (default). |
151
+ | `--step {1,2,3,all}` | `1` = process + summary CSV only; `2` = resample only (requires Stage 1); `3` = group + average only (requires Stage 2 and `groups_csv`); `all` = every stage that's configured (default). |
152
+ | `--groups-csv <path>` | Override the config's `groups_csv` to group repeat scans and average them. |
153
+ | `--group-method {mean,median,sum,min,max}` | Override the config's `group_agg_method` for Stage 3 (default `mean`). |
136
154
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
137
155
 
138
156
  ### Expected output layout
@@ -143,10 +161,11 @@ pipeline_outputs/
143
161
  │ ├── <truncated *.sig files>
144
162
  │ └── <input_dir_name>_processed_sig_summary.csv
145
163
  └── sig_resampled/<input_dir_name>/
146
- └── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
164
+ ├── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
165
+ └── <input_dir_name>_grouped_spectra.csv # only if groups_csv is configured
147
166
  ```
148
167
 
149
- `summary_csv_name` and `merged_csv_name` in the run config are suffixes; the real filenames are prefixed with the input directory name. Verify your config in [`config/README.md`](config/README.md).
168
+ `summary_csv_name`, `merged_csv_name`, and `grouped_csv_name` in the run config are suffixes; the real filenames are prefixed with the input directory name. Verify your config in [`config/README.md`](config/README.md).
150
169
 
151
170
  ---
152
171
 
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from pipeline.cli import _parse_args, main
10
+
11
+
12
+ def _write_config(path: Path, data: dict) -> Path:
13
+ path.write_text(json.dumps(data))
14
+ return path
15
+
16
+
17
+ def test_help_links_to_github_repo(
18
+ monkeypatch: pytest.MonkeyPatch,
19
+ capsys: pytest.CaptureFixture[str],
20
+ ) -> None:
21
+ monkeypatch.setattr(sys, "argv", ["svc-pipeline", "--help"])
22
+
23
+ with pytest.raises(SystemExit):
24
+ _parse_args()
25
+
26
+ assert "github.com/regs08/SVCProcessingPipeline" in capsys.readouterr().out
27
+
28
+
29
+ def test_parse_args_step_and_input_dir_default_to_all_and_none(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ monkeypatch.setattr(sys, "argv", ["svc-pipeline", "config.json"])
31
+
32
+ args = _parse_args()
33
+
34
+ assert args.config == "config.json"
35
+ assert args.step == "all"
36
+ assert args.input_dir is None
37
+ assert args.verbose is False
38
+
39
+
40
+ def test_parse_args_accepts_step_input_dir_and_verbose(monkeypatch: pytest.MonkeyPatch) -> None:
41
+ monkeypatch.setattr(
42
+ sys,
43
+ "argv",
44
+ ["svc-pipeline", "myconfig", "--step", "2", "--input-dir", "/some/dir", "--verbose"],
45
+ )
46
+
47
+ args = _parse_args()
48
+
49
+ assert args.step == "2"
50
+ assert args.input_dir == "/some/dir"
51
+ assert args.verbose is True
52
+
53
+
54
+ def test_parse_args_accepts_step_three_and_group_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
55
+ monkeypatch.setattr(
56
+ sys,
57
+ "argv",
58
+ ["svc-pipeline", "--step", "3", "--groups-csv", "naming_ids/groups.csv", "--group-method", "median"],
59
+ )
60
+
61
+ args = _parse_args()
62
+
63
+ assert args.step == "3"
64
+ assert args.groups_csv == "naming_ids/groups.csv"
65
+ assert args.group_method == "median"
66
+
67
+
68
+ def test_main_input_dir_override_bypasses_config_input_and_placeholder(
69
+ tmp_path: Path,
70
+ monkeypatch: pytest.MonkeyPatch,
71
+ capsys: pytest.CaptureFixture[str],
72
+ ) -> None:
73
+ # sig_input_dir is left as the unedited placeholder; --input-dir must
74
+ # still work without tripping the placeholder guard.
75
+ config_path = _write_config(
76
+ tmp_path / "config.json",
77
+ {
78
+ "sig_input_dir": "<PATH_TO_SIG_INPUT_ROOT>",
79
+ "processed_dir": "sig_processed",
80
+ "resampled_dir": "sig_resampled",
81
+ "output_dir": str(tmp_path / "out"),
82
+ "summary_csv_name": "summary.csv",
83
+ "merged_csv_name": "merged.csv",
84
+ },
85
+ )
86
+ empty_input = tmp_path / "empty_input"
87
+ empty_input.mkdir()
88
+
89
+ monkeypatch.setattr(
90
+ sys,
91
+ "argv",
92
+ ["svc-pipeline", str(config_path), "--input-dir", str(empty_input), "--step", "1"],
93
+ )
94
+
95
+ main()
96
+
97
+ captured = capsys.readouterr()
98
+ assert str(empty_input.resolve()) in captured.out
99
+ assert "not produced" in captured.out
100
+
101
+
102
+ def test_main_step_three_groups_existing_merged_csv(
103
+ tmp_path: Path,
104
+ monkeypatch: pytest.MonkeyPatch,
105
+ capsys: pytest.CaptureFixture[str],
106
+ ) -> None:
107
+ groups_csv = tmp_path / "groups.csv"
108
+ groups_csv.write_text("scan_id,name\n1,leaf_a\n2,leaf_a\n")
109
+
110
+ config_path = _write_config(
111
+ tmp_path / "config.json",
112
+ {
113
+ "sig_input_dir": "<PATH_TO_SIG_INPUT_ROOT>",
114
+ "processed_dir": "sig_processed",
115
+ "resampled_dir": "sig_resampled",
116
+ "output_dir": str(tmp_path / "out"),
117
+ "summary_csv_name": "summary.csv",
118
+ "merged_csv_name": "merged.csv",
119
+ "groups_csv": str(groups_csv),
120
+ },
121
+ )
122
+ input_dir = tmp_path / "myinput"
123
+ input_dir.mkdir()
124
+
125
+ resampled_dir = tmp_path / "out" / "sig_resampled" / "myinput"
126
+ resampled_dir.mkdir(parents=True)
127
+ merged_csv = resampled_dir / "myinput_merged.csv"
128
+ merged_csv.write_text("sample_name,400\nleaf.0001,1.0\nleaf.0002,3.0\n")
129
+
130
+ monkeypatch.setattr(
131
+ sys,
132
+ "argv",
133
+ ["svc-pipeline", str(config_path), "--input-dir", str(input_dir), "--step", "3"],
134
+ )
135
+
136
+ main()
137
+
138
+ grouped_csv = resampled_dir / "myinput_grouped_spectra.csv"
139
+ assert grouped_csv.exists()
140
+ captured = capsys.readouterr()
141
+ assert "grouped_csv" in captured.out
142
+ assert str(grouped_csv.resolve()) in captured.out
143
+
144
+
145
+ def test_main_init_config_writes_starter_and_exits(
146
+ tmp_path: Path,
147
+ monkeypatch: pytest.MonkeyPatch,
148
+ capsys: pytest.CaptureFixture[str],
149
+ ) -> None:
150
+ monkeypatch.chdir(tmp_path)
151
+ monkeypatch.setattr(sys, "argv", ["svc-pipeline", "--init-config"])
152
+
153
+ main()
154
+
155
+ written = tmp_path / "config" / "config.json"
156
+ assert written.is_file()
157
+ assert json.loads(written.read_text())["sig_input_dir"] == "<PATH_TO_SIG_INPUT_ROOT>"
158
+ assert str(written.resolve()) in capsys.readouterr().out
159
+
160
+
161
+ def test_main_init_config_refuses_to_overwrite_existing(
162
+ tmp_path: Path,
163
+ monkeypatch: pytest.MonkeyPatch,
164
+ ) -> None:
165
+ monkeypatch.chdir(tmp_path)
166
+ existing = tmp_path / "config" / "config.json"
167
+ existing.parent.mkdir()
168
+ existing.write_text('{"already": "here"}')
169
+ monkeypatch.setattr(sys, "argv", ["svc-pipeline", "--init-config"])
170
+
171
+ with pytest.raises(SystemExit, match="already exists"):
172
+ main()
173
+
174
+
175
+ def test_main_exits_on_placeholder_without_input_dir_override(
176
+ tmp_path: Path,
177
+ monkeypatch: pytest.MonkeyPatch,
178
+ ) -> None:
179
+ config_path = _write_config(tmp_path / "config.json", {"sig_input_dir": "<PATH_TO_SIG_INPUT_ROOT>"})
180
+
181
+ monkeypatch.setattr(sys, "argv", ["svc-pipeline", str(config_path)])
182
+
183
+ with pytest.raises(SystemExit, match="placeholder"):
184
+ main()
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import logging
4
5
  from pathlib import Path
5
6
 
@@ -21,6 +22,26 @@ def test_resolve_path_finds_bare_config_name(tmp_path: Path) -> None:
21
22
  assert RunConfig._resolve_path(tmp_path, "demo") == config_path
22
23
 
23
24
 
25
+ def test_write_starter_config_creates_config_json_with_placeholder(tmp_path: Path) -> None:
26
+ target = RunConfig.write_starter_config(tmp_path)
27
+
28
+ assert target == tmp_path / "config" / "config.json"
29
+ data = json.loads(target.read_text())
30
+ assert data["sig_input_dir"] == "<PATH_TO_SIG_INPUT_ROOT>"
31
+ assert data["processing"]["band_min"] == 400
32
+
33
+
34
+ def test_write_starter_config_refuses_to_overwrite_existing(tmp_path: Path) -> None:
35
+ existing = tmp_path / "config" / "config.json"
36
+ existing.parent.mkdir()
37
+ existing.write_text('{"already": "here"}')
38
+
39
+ with pytest.raises(SystemExit, match="already exists"):
40
+ RunConfig.write_starter_config(tmp_path)
41
+
42
+ assert json.loads(existing.read_text()) == {"already": "here"}
43
+
44
+
24
45
  def test_placeholder_guard_exits_with_guidance(tmp_path: Path) -> None:
25
46
  config_path = tmp_path / "config.json"
26
47
  config = RunConfig(
@@ -103,3 +124,58 @@ def test_settings_for_resolves_output_paths(tmp_path: Path) -> None:
103
124
  assert settings.merged_csv_name == "example_input_merged_spectra.csv"
104
125
  assert settings.correction_types["silver"] == "2517.9"
105
126
  assert settings.instrument_numbers["silver"] == "1202103"
127
+
128
+
129
+ def test_settings_for_grouping_defaults_when_unconfigured(tmp_path: Path) -> None:
130
+ data = {
131
+ "processed_dir": "sig_processed",
132
+ "resampled_dir": "sig_resampled",
133
+ "summary_csv_name": "summary.csv",
134
+ "merged_csv_name": "merged.csv",
135
+ }
136
+ config = RunConfig(data, path=tmp_path / "config.json", base_dir=tmp_path, logger=_logger())
137
+
138
+ settings = config.settings_for(Path("example_input"), verbose=False)
139
+
140
+ assert settings.groups_csv is None
141
+ assert settings.group_agg_method == "mean"
142
+ assert settings.grouped_csv_name == "example_input_grouped_spectra.csv"
143
+
144
+
145
+ def test_settings_for_resolves_groups_csv_from_config(tmp_path: Path) -> None:
146
+ data = {
147
+ "processed_dir": "sig_processed",
148
+ "resampled_dir": "sig_resampled",
149
+ "summary_csv_name": "summary.csv",
150
+ "merged_csv_name": "merged.csv",
151
+ "groups_csv": "naming_ids/groups.csv",
152
+ "group_agg_method": "median",
153
+ }
154
+ config = RunConfig(data, path=tmp_path / "config.json", base_dir=tmp_path, logger=_logger())
155
+
156
+ settings = config.settings_for(Path("example_input"), verbose=False)
157
+
158
+ assert settings.groups_csv == tmp_path / "naming_ids/groups.csv"
159
+ assert settings.group_agg_method == "median"
160
+
161
+
162
+ def test_settings_for_cli_overrides_take_priority_over_config(tmp_path: Path) -> None:
163
+ data = {
164
+ "processed_dir": "sig_processed",
165
+ "resampled_dir": "sig_resampled",
166
+ "summary_csv_name": "summary.csv",
167
+ "merged_csv_name": "merged.csv",
168
+ "groups_csv": "naming_ids/groups.csv",
169
+ "group_agg_method": "mean",
170
+ }
171
+ config = RunConfig(data, path=tmp_path / "config.json", base_dir=tmp_path, logger=_logger())
172
+
173
+ settings = config.settings_for(
174
+ Path("example_input"),
175
+ verbose=False,
176
+ groups_csv_override=tmp_path / "override_groups.csv",
177
+ group_method_override="max",
178
+ )
179
+
180
+ assert settings.groups_csv == tmp_path / "override_groups.csv"
181
+ assert settings.group_agg_method == "max"
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from pipeline.run_config import PipelineSettings
7
+ from pipeline.runner import Pipeline
8
+
9
+
10
+ def _settings(
11
+ tmp_path: Path,
12
+ input_dir: Path,
13
+ *,
14
+ groups_csv: Path | None = None,
15
+ group_agg_method: str = "mean",
16
+ ) -> PipelineSettings:
17
+ return PipelineSettings(
18
+ source_name=input_dir.name or "input",
19
+ input_dir=input_dir,
20
+ processed_dir=tmp_path / "processed",
21
+ resampled_dir=tmp_path / "resampled",
22
+ summary_csv=tmp_path / "processed" / "summary.csv",
23
+ merged_csv_name="merged.csv",
24
+ end_line_overrides={},
25
+ verbose=False,
26
+ processing_params={
27
+ "band_min": 400,
28
+ "band_max": 410,
29
+ "resample_fwhm_nm": 10.0,
30
+ "splice_interp_wvl": (5.0, 2.0),
31
+ "fixed_sensor": 2,
32
+ },
33
+ correction_types={"bronze": "2520.4", "silver": "2517.9"},
34
+ instrument_numbers={"bronze": "2212118", "silver": "1202103"},
35
+ groups_csv=groups_csv,
36
+ group_agg_method=group_agg_method,
37
+ grouped_csv_name="grouped.csv",
38
+ )
39
+
40
+
41
+ def test_process_sig_files_returns_none_for_missing_input(tmp_path: Path) -> None:
42
+ pipeline = Pipeline(_settings(tmp_path, tmp_path / "missing"), logging.getLogger("tests.runner"))
43
+
44
+ assert pipeline.process_sig_files() is None
45
+
46
+
47
+ def test_process_sig_files_returns_none_for_empty_input(tmp_path: Path) -> None:
48
+ input_dir = tmp_path / "empty"
49
+ input_dir.mkdir()
50
+ pipeline = Pipeline(_settings(tmp_path, input_dir), logging.getLogger("tests.runner"))
51
+
52
+ assert pipeline.process_sig_files() is None
53
+
54
+
55
+ def test_run_step_two_without_summary_does_not_resample(tmp_path: Path) -> None:
56
+ input_dir = tmp_path / "input"
57
+ input_dir.mkdir()
58
+ pipeline = Pipeline(_settings(tmp_path, input_dir), logging.getLogger("tests.runner"))
59
+
60
+ result = pipeline.run("2")
61
+
62
+ assert result == {"summary_csv": None, "merged_csv": None, "grouped_csv": None}
63
+
64
+
65
+ def test_run_all_skips_grouping_when_groups_csv_not_configured(tmp_path: Path) -> None:
66
+ input_dir = tmp_path / "input"
67
+ input_dir.mkdir()
68
+ pipeline = Pipeline(_settings(tmp_path, input_dir), logging.getLogger("tests.runner"))
69
+
70
+ result = pipeline.run("all")
71
+
72
+ assert result["grouped_csv"] is None
73
+
74
+
75
+ def test_group_and_average_writes_grouped_csv(tmp_path: Path) -> None:
76
+ resampled_dir = tmp_path / "resampled"
77
+ resampled_dir.mkdir(parents=True)
78
+ merged_csv = resampled_dir / "merged.csv"
79
+ merged_csv.write_text("sample_name,400,401\nleaf.0001,1.0,2.0\nleaf.0002,3.0,4.0\n")
80
+
81
+ groups_csv = tmp_path / "groups.csv"
82
+ groups_csv.write_text("scan_id,name\n1,leaf_a\n2,leaf_a\n")
83
+
84
+ settings = _settings(tmp_path, tmp_path / "input", groups_csv=groups_csv)
85
+ pipeline = Pipeline(settings, logging.getLogger("tests.runner"))
86
+
87
+ output = pipeline.group_and_average(merged_csv)
88
+
89
+ assert output == resampled_dir / "grouped.csv"
90
+ written = output.read_text()
91
+ assert "leaf_a" in written
92
+ assert "2.0" in written # mean(1.0, 3.0)
93
+ assert "3.0" in written # mean(2.0, 4.0)
94
+
95
+
96
+ def test_group_and_average_missing_groups_file_returns_none(tmp_path: Path) -> None:
97
+ resampled_dir = tmp_path / "resampled"
98
+ resampled_dir.mkdir(parents=True)
99
+ merged_csv = resampled_dir / "merged.csv"
100
+ merged_csv.write_text("sample_name,400\nleaf.0001,1.0\n")
101
+
102
+ settings = _settings(tmp_path, tmp_path / "input", groups_csv=tmp_path / "missing_groups.csv")
103
+ pipeline = Pipeline(settings, logging.getLogger("tests.runner"))
104
+
105
+ assert pipeline.group_and_average(merged_csv) is None
106
+
107
+
108
+ def test_run_step_three_without_merged_csv_logs_error(tmp_path: Path) -> None:
109
+ input_dir = tmp_path / "input"
110
+ input_dir.mkdir()
111
+ groups_csv = tmp_path / "groups.csv"
112
+ groups_csv.write_text("scan_id,name\n1,leaf_a\n")
113
+ pipeline = Pipeline(_settings(tmp_path, input_dir, groups_csv=groups_csv), logging.getLogger("tests.runner"))
114
+
115
+ result = pipeline.run("3")
116
+
117
+ assert result == {"summary_csv": None, "merged_csv": None, "grouped_csv": None}
118
+
119
+
120
+ def test_run_step_three_groups_existing_merged_csv(tmp_path: Path) -> None:
121
+ input_dir = tmp_path / "input"
122
+ input_dir.mkdir()
123
+ resampled_dir = tmp_path / "resampled"
124
+ resampled_dir.mkdir(parents=True)
125
+ merged_csv = resampled_dir / "merged.csv"
126
+ merged_csv.write_text("sample_name,400\nleaf.0001,1.0\nleaf.0002,3.0\n")
127
+ groups_csv = tmp_path / "groups.csv"
128
+ groups_csv.write_text("scan_id,name\n1,leaf_a\n2,leaf_a\n")
129
+
130
+ pipeline = Pipeline(_settings(tmp_path, input_dir, groups_csv=groups_csv), logging.getLogger("tests.runner"))
131
+
132
+ result = pipeline.run("3")
133
+
134
+ assert result["merged_csv"] == merged_csv
135
+ assert result["grouped_csv"] == resampled_dir / "grouped.csv"
136
+ assert result["grouped_csv"].exists()
@@ -1,85 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import sys
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
- from pipeline.cli import _parse_args, main
10
-
11
-
12
- def _write_config(path: Path, data: dict) -> Path:
13
- path.write_text(json.dumps(data))
14
- return path
15
-
16
-
17
- def test_parse_args_step_and_input_dir_default_to_all_and_none(monkeypatch: pytest.MonkeyPatch) -> None:
18
- monkeypatch.setattr(sys, "argv", ["svc-pipeline", "config.json"])
19
-
20
- args = _parse_args()
21
-
22
- assert args.config == "config.json"
23
- assert args.step == "all"
24
- assert args.input_dir is None
25
- assert args.verbose is False
26
-
27
-
28
- def test_parse_args_accepts_step_input_dir_and_verbose(monkeypatch: pytest.MonkeyPatch) -> None:
29
- monkeypatch.setattr(
30
- sys,
31
- "argv",
32
- ["svc-pipeline", "myconfig", "--step", "2", "--input-dir", "/some/dir", "--verbose"],
33
- )
34
-
35
- args = _parse_args()
36
-
37
- assert args.step == "2"
38
- assert args.input_dir == "/some/dir"
39
- assert args.verbose is True
40
-
41
-
42
- def test_main_input_dir_override_bypasses_config_input_and_placeholder(
43
- tmp_path: Path,
44
- monkeypatch: pytest.MonkeyPatch,
45
- capsys: pytest.CaptureFixture[str],
46
- ) -> None:
47
- # sig_input_dir is left as the unedited placeholder; --input-dir must
48
- # still work without tripping the placeholder guard.
49
- config_path = _write_config(
50
- tmp_path / "config.json",
51
- {
52
- "sig_input_dir": "<PATH_TO_SIG_INPUT_ROOT>",
53
- "processed_dir": "sig_processed",
54
- "resampled_dir": "sig_resampled",
55
- "output_dir": str(tmp_path / "out"),
56
- "summary_csv_name": "summary.csv",
57
- "merged_csv_name": "merged.csv",
58
- },
59
- )
60
- empty_input = tmp_path / "empty_input"
61
- empty_input.mkdir()
62
-
63
- monkeypatch.setattr(
64
- sys,
65
- "argv",
66
- ["svc-pipeline", str(config_path), "--input-dir", str(empty_input), "--step", "1"],
67
- )
68
-
69
- main()
70
-
71
- captured = capsys.readouterr()
72
- assert str(empty_input.resolve()) in captured.out
73
- assert "not produced" in captured.out
74
-
75
-
76
- def test_main_exits_on_placeholder_without_input_dir_override(
77
- tmp_path: Path,
78
- monkeypatch: pytest.MonkeyPatch,
79
- ) -> None:
80
- config_path = _write_config(tmp_path / "config.json", {"sig_input_dir": "<PATH_TO_SIG_INPUT_ROOT>"})
81
-
82
- monkeypatch.setattr(sys, "argv", ["svc-pipeline", str(config_path)])
83
-
84
- with pytest.raises(SystemExit, match="placeholder"):
85
- main()
@@ -1,53 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from pathlib import Path
5
-
6
- from pipeline.run_config import PipelineSettings
7
- from pipeline.runner import Pipeline
8
-
9
-
10
- def _settings(tmp_path: Path, input_dir: Path) -> PipelineSettings:
11
- return PipelineSettings(
12
- source_name=input_dir.name or "input",
13
- input_dir=input_dir,
14
- processed_dir=tmp_path / "processed",
15
- resampled_dir=tmp_path / "resampled",
16
- summary_csv=tmp_path / "processed" / "summary.csv",
17
- merged_csv_name="merged.csv",
18
- end_line_overrides={},
19
- verbose=False,
20
- processing_params={
21
- "band_min": 400,
22
- "band_max": 410,
23
- "resample_fwhm_nm": 10.0,
24
- "splice_interp_wvl": (5.0, 2.0),
25
- "fixed_sensor": 2,
26
- },
27
- correction_types={"bronze": "2520.4", "silver": "2517.9"},
28
- instrument_numbers={"bronze": "2212118", "silver": "1202103"},
29
- )
30
-
31
-
32
- def test_process_sig_files_returns_none_for_missing_input(tmp_path: Path) -> None:
33
- pipeline = Pipeline(_settings(tmp_path, tmp_path / "missing"), logging.getLogger("tests.runner"))
34
-
35
- assert pipeline.process_sig_files() is None
36
-
37
-
38
- def test_process_sig_files_returns_none_for_empty_input(tmp_path: Path) -> None:
39
- input_dir = tmp_path / "empty"
40
- input_dir.mkdir()
41
- pipeline = Pipeline(_settings(tmp_path, input_dir), logging.getLogger("tests.runner"))
42
-
43
- assert pipeline.process_sig_files() is None
44
-
45
-
46
- def test_run_step_two_without_summary_does_not_resample(tmp_path: Path) -> None:
47
- input_dir = tmp_path / "input"
48
- input_dir.mkdir()
49
- pipeline = Pipeline(_settings(tmp_path, input_dir), logging.getLogger("tests.runner"))
50
-
51
- result = pipeline.run("2")
52
-
53
- assert result == {"summary_csv": None, "merged_csv": None}
File without changes
File without changes