svc-processing 0.1.2__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 (27) hide show
  1. {svc_processing-0.1.2 → svc_processing-0.1.3}/PKG-INFO +13 -8
  2. {svc_processing-0.1.2 → svc_processing-0.1.3}/README.md +12 -7
  3. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/cli.py +33 -4
  4. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/run_config.py +19 -1
  5. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/runner.py +58 -5
  6. {svc_processing-0.1.2 → svc_processing-0.1.3}/pyproject.toml +1 -1
  7. {svc_processing-0.1.2 → svc_processing-0.1.3}/svc_processing.egg-info/PKG-INFO +13 -8
  8. {svc_processing-0.1.2 → svc_processing-0.1.3}/tests/test_cli.py +69 -0
  9. {svc_processing-0.1.2 → svc_processing-0.1.3}/tests/test_run_config.py +55 -0
  10. svc_processing-0.1.3/tests/test_runner.py +136 -0
  11. svc_processing-0.1.2/tests/test_runner.py +0 -53
  12. {svc_processing-0.1.2 → svc_processing-0.1.3}/LICENSE +0 -0
  13. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/__init__.py +0 -0
  14. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/processor.py +0 -0
  15. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/resampler.py +0 -0
  16. {svc_processing-0.1.2 → svc_processing-0.1.3}/pipeline/sig_processor.py +0 -0
  17. {svc_processing-0.1.2 → svc_processing-0.1.3}/setup.cfg +0 -0
  18. {svc_processing-0.1.2 → svc_processing-0.1.3}/svc_processing.egg-info/SOURCES.txt +0 -0
  19. {svc_processing-0.1.2 → svc_processing-0.1.3}/svc_processing.egg-info/dependency_links.txt +0 -0
  20. {svc_processing-0.1.2 → svc_processing-0.1.3}/svc_processing.egg-info/entry_points.txt +0 -0
  21. {svc_processing-0.1.2 → svc_processing-0.1.3}/svc_processing.egg-info/requires.txt +0 -0
  22. {svc_processing-0.1.2 → svc_processing-0.1.3}/svc_processing.egg-info/top_level.txt +0 -0
  23. {svc_processing-0.1.2 → svc_processing-0.1.3}/tests/test_pipeline_demo.py +0 -0
  24. {svc_processing-0.1.2 → svc_processing-0.1.3}/tests/test_processor.py +0 -0
  25. {svc_processing-0.1.2 → svc_processing-0.1.3}/tests/test_resampler_parity.py +0 -0
  26. {svc_processing-0.1.2 → svc_processing-0.1.3}/tests/test_resampler_public_api.py +0 -0
  27. {svc_processing-0.1.2 → 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.2
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>
@@ -70,9 +70,9 @@ Generated outputs (gitignored): `pipeline_outputs/sig_processed/<run>/`, `pipeli
70
70
  ```bash
71
71
  python3.11 -m venv .venv
72
72
  source .venv/bin/activate
73
- python -m pip install svc-processing
73
+ python -m pip install svc-processing # package name
74
74
 
75
- svc-pipeline --init-config # writes a starter config/config.json here
75
+ svc-pipeline --init-config # command name different from the package name above
76
76
  # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
77
77
  svc-pipeline
78
78
  ```
@@ -126,8 +126,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
126
126
  └────────────────────────────────────┘
127
127
  ```
128
128
 
129
- The `svc-pipeline` console script glues Stages 1 and 2 together through
130
- `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.
131
133
  The pipeline is **single-pass and idempotent per input directory** — previous
132
134
  processed `.sig` files in the target directory are deleted at the start of each
133
135
  run.
@@ -146,7 +148,9 @@ svc-pipeline [config] [options]
146
148
  |---|---|
147
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. |
148
150
  | `--input-dir <path>` | Override `sig_input_dir` and process only this directory. |
149
- | `--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`). |
150
154
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
151
155
 
152
156
  ### Expected output layout
@@ -157,10 +161,11 @@ pipeline_outputs/
157
161
  │ ├── <truncated *.sig files>
158
162
  │ └── <input_dir_name>_processed_sig_summary.csv
159
163
  └── sig_resampled/<input_dir_name>/
160
- └── <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
161
166
  ```
162
167
 
163
- `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).
164
169
 
165
170
  ---
166
171
 
@@ -37,9 +37,9 @@ Generated outputs (gitignored): `pipeline_outputs/sig_processed/<run>/`, `pipeli
37
37
  ```bash
38
38
  python3.11 -m venv .venv
39
39
  source .venv/bin/activate
40
- python -m pip install svc-processing
40
+ python -m pip install svc-processing # package name
41
41
 
42
- svc-pipeline --init-config # writes a starter config/config.json here
42
+ svc-pipeline --init-config # command name different from the package name above
43
43
  # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
44
44
  svc-pipeline
45
45
  ```
@@ -93,8 +93,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
93
93
  └────────────────────────────────────┘
94
94
  ```
95
95
 
96
- The `svc-pipeline` console script glues Stages 1 and 2 together through
97
- `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.
98
100
  The pipeline is **single-pass and idempotent per input directory** — previous
99
101
  processed `.sig` files in the target directory are deleted at the start of each
100
102
  run.
@@ -113,7 +115,9 @@ svc-pipeline [config] [options]
113
115
  |---|---|
114
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. |
115
117
  | `--input-dir <path>` | Override `sig_input_dir` and process only this directory. |
116
- | `--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`). |
117
121
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
118
122
 
119
123
  ### Expected output layout
@@ -124,10 +128,11 @@ pipeline_outputs/
124
128
  │ ├── <truncated *.sig files>
125
129
  │ └── <input_dir_name>_processed_sig_summary.csv
126
130
  └── sig_resampled/<input_dir_name>/
127
- └── <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
128
133
  ```
129
134
 
130
- `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).
131
136
 
132
137
  ---
133
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,9 +47,13 @@ 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",
@@ -55,6 +65,18 @@ def _parse_args() -> argparse.Namespace:
55
65
  action="store_true",
56
66
  help="Write a starter config/config.json in the current directory and exit.",
57
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
+ )
58
80
  return parser.parse_args()
59
81
 
60
82
 
@@ -85,9 +107,16 @@ def main() -> None:
85
107
  # warning for any non-default value before the per-directory work begins.
86
108
  run_config.processing_params()
87
109
 
110
+ groups_csv_override = Path(args.groups_csv).expanduser() if args.groups_csv else None
111
+
88
112
  overall_results: list[tuple[Path, dict[str, Path | None]]] = []
89
113
  for input_dir in input_dirs:
90
- 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
+ )
91
120
  results = Pipeline(settings, logger).run(args.step)
92
121
  overall_results.append((input_dir, results))
93
122
 
@@ -76,6 +76,9 @@ class PipelineSettings:
76
76
  processing_params: dict
77
77
  correction_types: dict[str, str]
78
78
  instrument_numbers: dict[str, str]
79
+ groups_csv: Path | None
80
+ group_agg_method: str
81
+ grouped_csv_name: str
79
82
 
80
83
 
81
84
  class RunConfig:
@@ -299,7 +302,14 @@ class RunConfig:
299
302
  self._logger.info("Instrument serials from config: %s", serials)
300
303
  return correction_types, instrument_numbers
301
304
 
302
- 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:
303
313
  """Build the fully-resolved :class:`PipelineSettings` for one input directory."""
304
314
  input_dir = Path(input_dir).expanduser()
305
315
  source_name = input_dir.name or "sig_input"
@@ -325,6 +335,11 @@ class RunConfig:
325
335
  if key is not None
326
336
  }
327
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
+
328
343
  return PipelineSettings(
329
344
  source_name=source_name,
330
345
  input_dir=input_dir,
@@ -337,4 +352,7 @@ class RunConfig:
337
352
  processing_params=self.processing_params(),
338
353
  correction_types=correction_types,
339
354
  instrument_numbers=instrument_numbers,
355
+ groups_csv=groups_csv,
356
+ group_agg_method=group_agg_method,
357
+ grouped_csv_name=grouped_csv_name,
340
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.2"
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.2
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>
@@ -70,9 +70,9 @@ Generated outputs (gitignored): `pipeline_outputs/sig_processed/<run>/`, `pipeli
70
70
  ```bash
71
71
  python3.11 -m venv .venv
72
72
  source .venv/bin/activate
73
- python -m pip install svc-processing
73
+ python -m pip install svc-processing # package name
74
74
 
75
- svc-pipeline --init-config # writes a starter config/config.json here
75
+ svc-pipeline --init-config # command name different from the package name above
76
76
  # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
77
77
  svc-pipeline
78
78
  ```
@@ -126,8 +126,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
126
126
  └────────────────────────────────────┘
127
127
  ```
128
128
 
129
- The `svc-pipeline` console script glues Stages 1 and 2 together through
130
- `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.
131
133
  The pipeline is **single-pass and idempotent per input directory** — previous
132
134
  processed `.sig` files in the target directory are deleted at the start of each
133
135
  run.
@@ -146,7 +148,9 @@ svc-pipeline [config] [options]
146
148
  |---|---|
147
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. |
148
150
  | `--input-dir <path>` | Override `sig_input_dir` and process only this directory. |
149
- | `--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`). |
150
154
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
151
155
 
152
156
  ### Expected output layout
@@ -157,10 +161,11 @@ pipeline_outputs/
157
161
  │ ├── <truncated *.sig files>
158
162
  │ └── <input_dir_name>_processed_sig_summary.csv
159
163
  └── sig_resampled/<input_dir_name>/
160
- └── <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
161
166
  ```
162
167
 
163
- `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).
164
169
 
165
170
  ---
166
171
 
@@ -14,6 +14,18 @@ def _write_config(path: Path, data: dict) -> Path:
14
14
  return path
15
15
 
16
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
+
17
29
  def test_parse_args_step_and_input_dir_default_to_all_and_none(monkeypatch: pytest.MonkeyPatch) -> None:
18
30
  monkeypatch.setattr(sys, "argv", ["svc-pipeline", "config.json"])
19
31
 
@@ -39,6 +51,20 @@ def test_parse_args_accepts_step_input_dir_and_verbose(monkeypatch: pytest.Monke
39
51
  assert args.verbose is True
40
52
 
41
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
+
42
68
  def test_main_input_dir_override_bypasses_config_input_and_placeholder(
43
69
  tmp_path: Path,
44
70
  monkeypatch: pytest.MonkeyPatch,
@@ -73,6 +99,49 @@ def test_main_input_dir_override_bypasses_config_input_and_placeholder(
73
99
  assert "not produced" in captured.out
74
100
 
75
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
+
76
145
  def test_main_init_config_writes_starter_and_exits(
77
146
  tmp_path: Path,
78
147
  monkeypatch: pytest.MonkeyPatch,
@@ -124,3 +124,58 @@ def test_settings_for_resolves_output_paths(tmp_path: Path) -> None:
124
124
  assert settings.merged_csv_name == "example_input_merged_spectra.csv"
125
125
  assert settings.correction_types["silver"] == "2517.9"
126
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,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