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.
- {svc_processing-0.1.1 → svc_processing-0.1.3}/PKG-INFO +25 -6
- {svc_processing-0.1.1 → svc_processing-0.1.3}/README.md +24 -5
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/cli.py +45 -4
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/run_config.py +56 -1
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/runner.py +58 -5
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pyproject.toml +1 -1
- {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/PKG-INFO +25 -6
- svc_processing-0.1.3/tests/test_cli.py +184 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_run_config.py +76 -0
- svc_processing-0.1.3/tests/test_runner.py +136 -0
- svc_processing-0.1.1/tests/test_cli.py +0 -85
- svc_processing-0.1.1/tests/test_runner.py +0 -53
- {svc_processing-0.1.1 → svc_processing-0.1.3}/LICENSE +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/__init__.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/processor.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/resampler.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/pipeline/sig_processor.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/setup.cfg +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/SOURCES.txt +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/dependency_links.txt +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/entry_points.txt +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/requires.txt +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/svc_processing.egg-info/top_level.txt +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_pipeline_demo.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_processor.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_resampler_parity.py +0 -0
- {svc_processing-0.1.1 → svc_processing-0.1.3}/tests/test_resampler_public_api.py +0 -0
- {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.
|
|
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
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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(
|
|
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=
|
|
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(
|
|
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(
|
|
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
|
|
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), ``"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: svc-processing
|
|
3
|
-
Version: 0.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
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|