svc-processing 0.1.2__tar.gz → 0.1.4__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.4}/PKG-INFO +30 -11
  2. {svc_processing-0.1.2 → svc_processing-0.1.4}/README.md +29 -10
  3. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/cli.py +33 -4
  4. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/run_config.py +19 -1
  5. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/runner.py +58 -5
  6. {svc_processing-0.1.2 → svc_processing-0.1.4}/pyproject.toml +1 -1
  7. {svc_processing-0.1.2 → svc_processing-0.1.4}/svc_processing.egg-info/PKG-INFO +30 -11
  8. {svc_processing-0.1.2 → svc_processing-0.1.4}/tests/test_cli.py +69 -0
  9. {svc_processing-0.1.2 → svc_processing-0.1.4}/tests/test_run_config.py +55 -0
  10. svc_processing-0.1.4/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.4}/LICENSE +0 -0
  13. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/__init__.py +0 -0
  14. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/processor.py +0 -0
  15. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/resampler.py +0 -0
  16. {svc_processing-0.1.2 → svc_processing-0.1.4}/pipeline/sig_processor.py +0 -0
  17. {svc_processing-0.1.2 → svc_processing-0.1.4}/setup.cfg +0 -0
  18. {svc_processing-0.1.2 → svc_processing-0.1.4}/svc_processing.egg-info/SOURCES.txt +0 -0
  19. {svc_processing-0.1.2 → svc_processing-0.1.4}/svc_processing.egg-info/dependency_links.txt +0 -0
  20. {svc_processing-0.1.2 → svc_processing-0.1.4}/svc_processing.egg-info/entry_points.txt +0 -0
  21. {svc_processing-0.1.2 → svc_processing-0.1.4}/svc_processing.egg-info/requires.txt +0 -0
  22. {svc_processing-0.1.2 → svc_processing-0.1.4}/svc_processing.egg-info/top_level.txt +0 -0
  23. {svc_processing-0.1.2 → svc_processing-0.1.4}/tests/test_pipeline_demo.py +0 -0
  24. {svc_processing-0.1.2 → svc_processing-0.1.4}/tests/test_processor.py +0 -0
  25. {svc_processing-0.1.2 → svc_processing-0.1.4}/tests/test_resampler_parity.py +0 -0
  26. {svc_processing-0.1.2 → svc_processing-0.1.4}/tests/test_resampler_public_api.py +0 -0
  27. {svc_processing-0.1.2 → svc_processing-0.1.4}/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.4
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,21 +70,35 @@ 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
  ```
79
79
 
80
- **Working from a clone of this repo** (config/config.json already exists):
80
+ **Working from a clone of this repo** (config/config.json already exists) — run the
81
+ setup script once, then activate the environment it created:
82
+
83
+ ```bash
84
+ ./setup.sh # creates .venv, installs everything, stages the demo data
85
+ # ./setup.sh --dev # add the test & lint tools for development
86
+
87
+ source .venv/bin/activate # macOS / Linux
88
+ # .venv\Scripts\activate # Windows PowerShell
89
+
90
+ # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
91
+ svc-pipeline config.json
92
+ ```
93
+
94
+ The script always installs into *this* repo's `.venv` no matter which folder you run
95
+ it from, so it avoids the "Directory '.' is not installable" and "No module named …"
96
+ errors that come from being in the wrong directory. Prefer to do it by hand?
81
97
 
82
98
  ```bash
83
99
  python3.11 -m venv .venv
84
100
  source .venv/bin/activate
85
101
  python -m pip install -e ".[dev,demo]"
86
-
87
- # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
88
102
  svc-pipeline config.json
89
103
  ```
90
104
 
@@ -126,8 +140,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
126
140
  └────────────────────────────────────┘
127
141
  ```
128
142
 
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.
143
+ The `svc-pipeline` console script runs Stages 1 and 2 through `pipeline.cli`,
144
+ and Stage 3 too once a `groups_csv` is configured (or passed via
145
+ `--groups-csv`) — see [`config/README.md`](config/README.md#grouping--stage-3-optional).
146
+ Stage 3 is also available directly from notebooks against the Stage 2 output.
131
147
  The pipeline is **single-pass and idempotent per input directory** — previous
132
148
  processed `.sig` files in the target directory are deleted at the start of each
133
149
  run.
@@ -146,7 +162,9 @@ svc-pipeline [config] [options]
146
162
  |---|---|
147
163
  | `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
164
  | `--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). |
165
+ | `--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). |
166
+ | `--groups-csv <path>` | Override the config's `groups_csv` to group repeat scans and average them. |
167
+ | `--group-method {mean,median,sum,min,max}` | Override the config's `group_agg_method` for Stage 3 (default `mean`). |
150
168
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
151
169
 
152
170
  ### Expected output layout
@@ -157,10 +175,11 @@ pipeline_outputs/
157
175
  │ ├── <truncated *.sig files>
158
176
  │ └── <input_dir_name>_processed_sig_summary.csv
159
177
  └── sig_resampled/<input_dir_name>/
160
- └── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
178
+ ├── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
179
+ └── <input_dir_name>_grouped_spectra.csv # only if groups_csv is configured
161
180
  ```
162
181
 
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).
182
+ `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
183
 
165
184
  ---
166
185
 
@@ -37,21 +37,35 @@ 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
  ```
46
46
 
47
- **Working from a clone of this repo** (config/config.json already exists):
47
+ **Working from a clone of this repo** (config/config.json already exists) — run the
48
+ setup script once, then activate the environment it created:
49
+
50
+ ```bash
51
+ ./setup.sh # creates .venv, installs everything, stages the demo data
52
+ # ./setup.sh --dev # add the test & lint tools for development
53
+
54
+ source .venv/bin/activate # macOS / Linux
55
+ # .venv\Scripts\activate # Windows PowerShell
56
+
57
+ # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
58
+ svc-pipeline config.json
59
+ ```
60
+
61
+ The script always installs into *this* repo's `.venv` no matter which folder you run
62
+ it from, so it avoids the "Directory '.' is not installable" and "No module named …"
63
+ errors that come from being in the wrong directory. Prefer to do it by hand?
48
64
 
49
65
  ```bash
50
66
  python3.11 -m venv .venv
51
67
  source .venv/bin/activate
52
68
  python -m pip install -e ".[dev,demo]"
53
-
54
- # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
55
69
  svc-pipeline config.json
56
70
  ```
57
71
 
@@ -93,8 +107,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
93
107
  └────────────────────────────────────┘
94
108
  ```
95
109
 
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.
110
+ The `svc-pipeline` console script runs Stages 1 and 2 through `pipeline.cli`,
111
+ and Stage 3 too once a `groups_csv` is configured (or passed via
112
+ `--groups-csv`) — see [`config/README.md`](config/README.md#grouping--stage-3-optional).
113
+ Stage 3 is also available directly from notebooks against the Stage 2 output.
98
114
  The pipeline is **single-pass and idempotent per input directory** — previous
99
115
  processed `.sig` files in the target directory are deleted at the start of each
100
116
  run.
@@ -113,7 +129,9 @@ svc-pipeline [config] [options]
113
129
  |---|---|
114
130
  | `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
131
  | `--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). |
132
+ | `--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). |
133
+ | `--groups-csv <path>` | Override the config's `groups_csv` to group repeat scans and average them. |
134
+ | `--group-method {mean,median,sum,min,max}` | Override the config's `group_agg_method` for Stage 3 (default `mean`). |
117
135
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
118
136
 
119
137
  ### Expected output layout
@@ -124,10 +142,11 @@ pipeline_outputs/
124
142
  │ ├── <truncated *.sig files>
125
143
  │ └── <input_dir_name>_processed_sig_summary.csv
126
144
  └── sig_resampled/<input_dir_name>/
127
- └── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
145
+ ├── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
146
+ └── <input_dir_name>_grouped_spectra.csv # only if groups_csv is configured
128
147
  ```
129
148
 
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).
149
+ `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
150
 
132
151
  ---
133
152
 
@@ -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.4"
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.4
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,21 +70,35 @@ 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
  ```
79
79
 
80
- **Working from a clone of this repo** (config/config.json already exists):
80
+ **Working from a clone of this repo** (config/config.json already exists) — run the
81
+ setup script once, then activate the environment it created:
82
+
83
+ ```bash
84
+ ./setup.sh # creates .venv, installs everything, stages the demo data
85
+ # ./setup.sh --dev # add the test & lint tools for development
86
+
87
+ source .venv/bin/activate # macOS / Linux
88
+ # .venv\Scripts\activate # Windows PowerShell
89
+
90
+ # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
91
+ svc-pipeline config.json
92
+ ```
93
+
94
+ The script always installs into *this* repo's `.venv` no matter which folder you run
95
+ it from, so it avoids the "Directory '.' is not installable" and "No module named …"
96
+ errors that come from being in the wrong directory. Prefer to do it by hand?
81
97
 
82
98
  ```bash
83
99
  python3.11 -m venv .venv
84
100
  source .venv/bin/activate
85
101
  python -m pip install -e ".[dev,demo]"
86
-
87
- # Edit config/config.json — replace "<PATH_TO_SIG_INPUT_ROOT>" with your data path.
88
102
  svc-pipeline config.json
89
103
  ```
90
104
 
@@ -126,8 +140,10 @@ Outputs land under `pipeline_outputs/` by default. See [`config/README.md`](conf
126
140
  └────────────────────────────────────┘
127
141
  ```
128
142
 
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.
143
+ The `svc-pipeline` console script runs Stages 1 and 2 through `pipeline.cli`,
144
+ and Stage 3 too once a `groups_csv` is configured (or passed via
145
+ `--groups-csv`) — see [`config/README.md`](config/README.md#grouping--stage-3-optional).
146
+ Stage 3 is also available directly from notebooks against the Stage 2 output.
131
147
  The pipeline is **single-pass and idempotent per input directory** — previous
132
148
  processed `.sig` files in the target directory are deleted at the start of each
133
149
  run.
@@ -146,7 +162,9 @@ svc-pipeline [config] [options]
146
162
  |---|---|
147
163
  | `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
164
  | `--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). |
165
+ | `--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). |
166
+ | `--groups-csv <path>` | Override the config's `groups_csv` to group repeat scans and average them. |
167
+ | `--group-method {mean,median,sum,min,max}` | Override the config's `group_agg_method` for Stage 3 (default `mean`). |
150
168
  | `--verbose` | Print INFO/DEBUG logs before and after each stage. |
151
169
 
152
170
  ### Expected output layout
@@ -157,10 +175,11 @@ pipeline_outputs/
157
175
  │ ├── <truncated *.sig files>
158
176
  │ └── <input_dir_name>_processed_sig_summary.csv
159
177
  └── sig_resampled/<input_dir_name>/
160
- └── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
178
+ ├── <input_dir_name>_merged_spectra.csv # 2101 columns (400–2500 nm)
179
+ └── <input_dir_name>_grouped_spectra.csv # only if groups_csv is configured
161
180
  ```
162
181
 
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).
182
+ `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
183
 
165
184
  ---
166
185
 
@@ -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