svc-processing 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipeline/__init__.py +0 -0
- pipeline/cli.py +92 -0
- pipeline/processor.py +582 -0
- pipeline/resampler.py +495 -0
- pipeline/run_config.py +303 -0
- pipeline/runner.py +216 -0
- pipeline/sig_processor.py +258 -0
- svc_processing-0.1.1.dist-info/METADATA +250 -0
- svc_processing-0.1.1.dist-info/RECORD +13 -0
- svc_processing-0.1.1.dist-info/WHEEL +5 -0
- svc_processing-0.1.1.dist-info/entry_points.txt +2 -0
- svc_processing-0.1.1.dist-info/licenses/LICENSE +674 -0
- svc_processing-0.1.1.dist-info/top_level.txt +1 -0
pipeline/__init__.py
ADDED
|
File without changes
|
pipeline/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Command-line interface for the SIG processing pipeline.
|
|
2
|
+
|
|
3
|
+
This module is thin glue: it parses arguments, configures logging, and wires the
|
|
4
|
+
two collaborators together for each input directory —
|
|
5
|
+
|
|
6
|
+
* :class:`~pipeline.run_config.RunConfig` — loads the run config and builds the
|
|
7
|
+
per-directory settings (*what to run*).
|
|
8
|
+
* :class:`~pipeline.runner.Pipeline` — runs the processing + resampling stages
|
|
9
|
+
(*do the work*).
|
|
10
|
+
|
|
11
|
+
Invoke it as ``svc-pipeline [config]`` after installation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .run_config import RunConfig
|
|
21
|
+
from .runner import Pipeline
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _configure_logging(verbose: bool) -> logging.Logger:
|
|
25
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
26
|
+
logging.basicConfig(level=level, format="%(levelname)s: %(message)s", force=True)
|
|
27
|
+
return logging.getLogger("sig_pipeline")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_args() -> argparse.Namespace:
|
|
31
|
+
parser = argparse.ArgumentParser(description="Run the SIG processing pipeline")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"config",
|
|
34
|
+
nargs="?",
|
|
35
|
+
default="config.json",
|
|
36
|
+
help="Run-config JSON. Bare names resolve under config/ (default: config.json).",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--input-dir",
|
|
40
|
+
help="Override the config's sig_input_dir and process only this directory.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--step",
|
|
44
|
+
choices=["1", "2", "all"],
|
|
45
|
+
default="all",
|
|
46
|
+
help="Which step to run: 1=process+summary CSV, 2=Python resampling only, all=run both steps.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--verbose",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Enable verbose logging before and after each processing step",
|
|
52
|
+
)
|
|
53
|
+
return parser.parse_args()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
args = _parse_args()
|
|
58
|
+
logger = _configure_logging(args.verbose)
|
|
59
|
+
|
|
60
|
+
# Resolve relative config, calibration, and output paths against the current
|
|
61
|
+
# working directory so the installed `svc-pipeline` command works from any
|
|
62
|
+
# directory. Absolute paths in the config are always honoured as-is.
|
|
63
|
+
base_dir = Path.cwd()
|
|
64
|
+
run_config = RunConfig.load(base_dir, str(args.config), logger)
|
|
65
|
+
run_config.ensure_no_placeholder(args.input_dir)
|
|
66
|
+
|
|
67
|
+
if args.input_dir:
|
|
68
|
+
input_dirs = [Path(str(args.input_dir)).expanduser()]
|
|
69
|
+
else:
|
|
70
|
+
input_dirs = run_config.input_directories()
|
|
71
|
+
|
|
72
|
+
# Resolve the processing block once, up front: emits a one-time parity
|
|
73
|
+
# warning for any non-default value before the per-directory work begins.
|
|
74
|
+
run_config.processing_params()
|
|
75
|
+
|
|
76
|
+
overall_results: list[tuple[Path, dict[str, Path | None]]] = []
|
|
77
|
+
for input_dir in input_dirs:
|
|
78
|
+
settings = run_config.settings_for(input_dir, verbose=args.verbose)
|
|
79
|
+
results = Pipeline(settings, logger).run(args.step)
|
|
80
|
+
overall_results.append((input_dir, results))
|
|
81
|
+
|
|
82
|
+
for directory, outputs in overall_results:
|
|
83
|
+
print(f"\nInput directory: {directory.resolve()}")
|
|
84
|
+
for label, path in outputs.items():
|
|
85
|
+
if path:
|
|
86
|
+
print(f" {label}: {Path(path).resolve()}")
|
|
87
|
+
else:
|
|
88
|
+
print(f" {label}: not produced")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
pipeline/processor.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import warnings
|
|
5
|
+
from collections import namedtuple
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
GroupTuple = namedtuple("GroupTuple", ["members", "name"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class GroupSpec:
|
|
19
|
+
"""Tuple-like specification for grouping spectra, with optional display name."""
|
|
20
|
+
members: Sequence[int]
|
|
21
|
+
name: str | None = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
normalized_members = tuple(int(m) for m in self.members)
|
|
25
|
+
object.__setattr__(self, "members", normalized_members)
|
|
26
|
+
|
|
27
|
+
def to_namedtuple(self) -> GroupTuple:
|
|
28
|
+
return GroupTuple(self.members, self.name)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_csv(
|
|
32
|
+
cls,
|
|
33
|
+
csv_path: str | Path,
|
|
34
|
+
*,
|
|
35
|
+
members_column: str = "scans",
|
|
36
|
+
id_column: str = "scan_id",
|
|
37
|
+
name_column: str = "name",
|
|
38
|
+
exclude_columns: Sequence[str] = ("reference",),
|
|
39
|
+
delimiter: str = ",",
|
|
40
|
+
return_namedtuple: bool = True,
|
|
41
|
+
) -> list["GroupSpec" | GroupTuple]:
|
|
42
|
+
df = pd.read_csv(csv_path)
|
|
43
|
+
|
|
44
|
+
column_lookup = {col.lower(): col for col in df.columns}
|
|
45
|
+
|
|
46
|
+
drop_columns = [
|
|
47
|
+
column_lookup[col.lower()]
|
|
48
|
+
for col in exclude_columns
|
|
49
|
+
if col.lower() in column_lookup
|
|
50
|
+
]
|
|
51
|
+
if drop_columns:
|
|
52
|
+
df = df.drop(columns=drop_columns)
|
|
53
|
+
column_lookup = {col.lower(): col for col in df.columns}
|
|
54
|
+
|
|
55
|
+
resolved_members_col = column_lookup.get(members_column.lower())
|
|
56
|
+
resolved_id_col = column_lookup.get(id_column.lower())
|
|
57
|
+
resolved_name_col = column_lookup.get(name_column.lower())
|
|
58
|
+
|
|
59
|
+
if resolved_members_col is None and resolved_id_col is None:
|
|
60
|
+
raise KeyError(
|
|
61
|
+
f"Expected '{members_column}' or '{id_column}' in {csv_path}; none found."
|
|
62
|
+
)
|
|
63
|
+
if resolved_name_col is None:
|
|
64
|
+
raise KeyError(f"Column '{name_column}' not found in {csv_path}.")
|
|
65
|
+
|
|
66
|
+
specs: list["GroupSpec" | GroupTuple] = []
|
|
67
|
+
for _, row in df.iterrows():
|
|
68
|
+
if resolved_members_col is not None:
|
|
69
|
+
members_value = row[resolved_members_col]
|
|
70
|
+
else:
|
|
71
|
+
members_value = row.get(resolved_id_col)
|
|
72
|
+
if pd.isna(members_value):
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if isinstance(members_value, str):
|
|
76
|
+
tokens = [token.strip() for token in members_value.replace(";", delimiter).split(delimiter)]
|
|
77
|
+
members = tuple(int(token) for token in tokens if token)
|
|
78
|
+
elif isinstance(members_value, Iterable):
|
|
79
|
+
members = tuple(int(v) for v in members_value)
|
|
80
|
+
else:
|
|
81
|
+
members = (int(members_value),)
|
|
82
|
+
|
|
83
|
+
if not members:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
name_value = row.get(resolved_name_col)
|
|
87
|
+
group_name = None if pd.isna(name_value) else str(name_value).strip() or None
|
|
88
|
+
|
|
89
|
+
if group_name and group_name.lower() == "reference":
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
spec = cls(members=members, name=group_name)
|
|
93
|
+
specs.append(spec.to_namedtuple() if return_namedtuple else spec)
|
|
94
|
+
|
|
95
|
+
return specs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SVCDataProcessor:
|
|
99
|
+
"""Compact OO processor for SVC spectra workflows."""
|
|
100
|
+
SigEntry = namedtuple("SigEntry", ["index", "base_name", "number"])
|
|
101
|
+
SAMPLE_NAME_PATTERN = re.compile(r"^(?P<base>.+?)\.(?P<num>\d+)(?:\.sig)?$", re.IGNORECASE)
|
|
102
|
+
TRAILING_DIGITS_PATTERN = re.compile(r"^(?P<base>.*?)(?P<num>\d+)(?:\.sig)?$", re.IGNORECASE)
|
|
103
|
+
|
|
104
|
+
def _require_attributes(self, *names: str, message: str) -> None:
|
|
105
|
+
missing = [name for name in names if not hasattr(self, name)]
|
|
106
|
+
if missing:
|
|
107
|
+
raise RuntimeError(message)
|
|
108
|
+
|
|
109
|
+
# ---------- IO ----------
|
|
110
|
+
def load_csv(self, file_path: str, **kwargs):
|
|
111
|
+
"""Load CSV file. Pass any additional pandas.read_csv() arguments via kwargs."""
|
|
112
|
+
self.df: pd.DataFrame = pd.read_csv(file_path, **kwargs)
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
# ---------- Structure ----------
|
|
116
|
+
def split_columns(self, name_col: str | None = None):
|
|
117
|
+
self._require_attributes("df", message="Call load_csv first.")
|
|
118
|
+
self.name_col: str = name_col or self.df.columns[0]
|
|
119
|
+
num_cols = self.df.select_dtypes(include='number').columns.tolist()
|
|
120
|
+
xlike_cols = [c for c in self.df.columns if isinstance(c, str) and c.startswith("X")]
|
|
121
|
+
self.wavelength_cols: list[str] = list(dict.fromkeys(num_cols + xlike_cols))
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
# ---------- Parsing ----------
|
|
125
|
+
@classmethod
|
|
126
|
+
def normalize_sample_name(cls, value: str | int | float) -> str:
|
|
127
|
+
raw = "" if value is None else str(value).strip()
|
|
128
|
+
if not raw:
|
|
129
|
+
raise ValueError("Sample name is empty or None.")
|
|
130
|
+
|
|
131
|
+
name = raw[:-4] if raw.lower().endswith(".sig") else raw
|
|
132
|
+
|
|
133
|
+
match = cls.SAMPLE_NAME_PATTERN.match(name)
|
|
134
|
+
if not match:
|
|
135
|
+
match = cls.TRAILING_DIGITS_PATTERN.match(name)
|
|
136
|
+
|
|
137
|
+
if not match:
|
|
138
|
+
raise ValueError(f"Sample name '{raw}' does not match expected pattern.")
|
|
139
|
+
|
|
140
|
+
base = match.group("base").rstrip(".")
|
|
141
|
+
num = match.group("num")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
padded_num = str(int(num)).zfill(4)
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
raise ValueError(f"Sample number '{num}' in '{raw}' is not numeric.") from exc
|
|
147
|
+
|
|
148
|
+
return f"{base}.{padded_num}"
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def normalize_sample_column(
|
|
152
|
+
cls,
|
|
153
|
+
df: pd.DataFrame,
|
|
154
|
+
column: str = "name",
|
|
155
|
+
*,
|
|
156
|
+
inplace: bool = False,
|
|
157
|
+
) -> pd.DataFrame:
|
|
158
|
+
target = df if inplace else df.copy()
|
|
159
|
+
target[column] = target[column].astype(str).apply(cls.normalize_sample_name)
|
|
160
|
+
return target
|
|
161
|
+
|
|
162
|
+
def extract_sig_entries(self):
|
|
163
|
+
self._require_attributes("df", "name_col", message="Call split_columns first.")
|
|
164
|
+
names = self.df[self.name_col].astype(str).tolist()
|
|
165
|
+
out = []
|
|
166
|
+
for i, n in enumerate(names):
|
|
167
|
+
try:
|
|
168
|
+
normalized = self.normalize_sample_name(n)
|
|
169
|
+
base_part, number_part = normalized.rsplit(".", 1)
|
|
170
|
+
number_value = int(number_part)
|
|
171
|
+
self.df.at[i, self.name_col] = normalized
|
|
172
|
+
out.append(
|
|
173
|
+
self.SigEntry(index=i, base_name=base_part, number=number_value)
|
|
174
|
+
)
|
|
175
|
+
except ValueError:
|
|
176
|
+
warnings.warn(f"Unable to normalize sample name '{n}' at index {i}.", UserWarning)
|
|
177
|
+
out.append(
|
|
178
|
+
self.SigEntry(index=i, base_name=n, number=None)
|
|
179
|
+
)
|
|
180
|
+
self.entries: list[self.SigEntry] = out
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
# ---------- Grouping ----------
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _ensure_iterable_group(g: int | Iterable[int]) -> Iterable[int]:
|
|
186
|
+
return (g,) if isinstance(g, int) else g
|
|
187
|
+
|
|
188
|
+
def group_by(self, groups: Sequence[int | Iterable[int]], *, by: Literal["number", "index"] = "number"):
|
|
189
|
+
self._require_attributes("entries", message="Call extract_sig_entries first.")
|
|
190
|
+
|
|
191
|
+
available_values = {getattr(e, by) for e in self.entries}
|
|
192
|
+
lookup = {}
|
|
193
|
+
for e in self.entries:
|
|
194
|
+
key = getattr(e, by)
|
|
195
|
+
lookup.setdefault(key, []).append(e)
|
|
196
|
+
|
|
197
|
+
grouped: list[list[self.SigEntry]] = []
|
|
198
|
+
used_values = set()
|
|
199
|
+
|
|
200
|
+
for g in groups:
|
|
201
|
+
vals = self._ensure_iterable_group(g)
|
|
202
|
+
bucket: list[self.SigEntry] = []
|
|
203
|
+
for val in vals:
|
|
204
|
+
if val not in available_values:
|
|
205
|
+
warnings.warn(f"Cannot find {by}={val}", UserWarning)
|
|
206
|
+
else:
|
|
207
|
+
bucket.extend(lookup[val])
|
|
208
|
+
used_values.add(val)
|
|
209
|
+
grouped.append(bucket)
|
|
210
|
+
|
|
211
|
+
uncovered_vals = available_values - used_values
|
|
212
|
+
ungrouped_entries = [e for e in self.entries if getattr(e, by) in uncovered_vals]
|
|
213
|
+
if ungrouped_entries:
|
|
214
|
+
warnings.warn(
|
|
215
|
+
f"Entries {[getattr(e, by) for e in ungrouped_entries]} do not belong to any group",
|
|
216
|
+
UserWarning
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self.grouped_entries: list[list[self.SigEntry]] = grouped
|
|
220
|
+
self.ungrouped_entries: list[self.SigEntry] = ungrouped_entries
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
# ---------- Averaging ----------
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _select_numeric_cols(df: pd.DataFrame, cols: Iterable[str] | None) -> list[str]:
|
|
226
|
+
return list(cols) if cols is not None else list(df.select_dtypes(include="number").columns)
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _validate_group_indices(df: pd.DataFrame, group: Sequence["SVCDataProcessor.SigEntry"], group_id: int) -> None:
|
|
230
|
+
n = len(df)
|
|
231
|
+
bad = [e.index for e in group if e.index < 0 or e.index >= n]
|
|
232
|
+
if bad:
|
|
233
|
+
raise IndexError(f"Group {group_id} has out-of-bounds indices {bad} for DataFrame with {n} rows.")
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _row_indices(group: Sequence["SVCDataProcessor.SigEntry"]) -> list[int]:
|
|
237
|
+
return [e.index for e in group]
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _format_indices_display(row_idxs: Sequence[int], index_base: int) -> str:
|
|
241
|
+
if index_base not in (0, 1):
|
|
242
|
+
raise ValueError("index_base must be 0 or 1.")
|
|
243
|
+
indices = ",".join(str(i + index_base) for i in row_idxs)
|
|
244
|
+
return f"rows[{indices}]"
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _pick_sample_name(df: pd.DataFrame, name_col: str, group: Sequence["SVCDataProcessor.SigEntry"], row_idxs: Sequence[int], strategy: str = "base_first") -> str:
|
|
248
|
+
base_names = [e.base_name for e in group if e.base_name]
|
|
249
|
+
first_row_name = str(df.iloc[row_idxs[0]][name_col])
|
|
250
|
+
if strategy == "first_cell":
|
|
251
|
+
return first_row_name
|
|
252
|
+
if strategy == "concat_base":
|
|
253
|
+
seen, uniq = set(), []
|
|
254
|
+
for b in base_names:
|
|
255
|
+
if b not in seen:
|
|
256
|
+
seen.add(b)
|
|
257
|
+
uniq.append(b)
|
|
258
|
+
return ";".join(uniq) if uniq else first_row_name
|
|
259
|
+
if base_names:
|
|
260
|
+
unique_bases = set(base_names)
|
|
261
|
+
if len(unique_bases) > 1:
|
|
262
|
+
warnings.warn(
|
|
263
|
+
f"Group mixes base names {sorted(unique_bases)}; using '{base_names[0]}' (strategy='base_first').",
|
|
264
|
+
UserWarning
|
|
265
|
+
)
|
|
266
|
+
first_number = next((e.number for e in group if e.number is not None), None)
|
|
267
|
+
if first_number is not None:
|
|
268
|
+
return f"{base_names[0]}.{str(first_number).zfill(4)}"
|
|
269
|
+
return base_names[0]
|
|
270
|
+
return first_row_name
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _generate_sample_name_with_min(base_name: str, group: Sequence["SVCDataProcessor.SigEntry"]) -> str:
|
|
274
|
+
numbers = [e.number for e in group if e.number is not None]
|
|
275
|
+
if not numbers:
|
|
276
|
+
return base_name
|
|
277
|
+
return f"{base_name}_{min(numbers)}"
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _aggregate_rows(
|
|
281
|
+
df: pd.DataFrame,
|
|
282
|
+
row_idxs: Sequence[int],
|
|
283
|
+
cols: Sequence[str],
|
|
284
|
+
method: Literal["mean", "median", "sum", "min", "max"],
|
|
285
|
+
) -> pd.DataFrame:
|
|
286
|
+
subset = df.iloc[row_idxs][list(cols)]
|
|
287
|
+
if method == "mean":
|
|
288
|
+
aggregated = subset.mean(numeric_only=True, skipna=True)
|
|
289
|
+
elif method == "median":
|
|
290
|
+
aggregated = subset.median(numeric_only=True, skipna=True)
|
|
291
|
+
elif method == "sum":
|
|
292
|
+
aggregated = subset.sum(numeric_only=True, skipna=True)
|
|
293
|
+
elif method == "min":
|
|
294
|
+
aggregated = subset.min(numeric_only=True, skipna=True)
|
|
295
|
+
elif method == "max":
|
|
296
|
+
aggregated = subset.max(numeric_only=True, skipna=True)
|
|
297
|
+
else:
|
|
298
|
+
raise ValueError(f"Unsupported aggregation method '{method}'.")
|
|
299
|
+
return aggregated.to_frame().T
|
|
300
|
+
|
|
301
|
+
def average_groups(
|
|
302
|
+
self,
|
|
303
|
+
*,
|
|
304
|
+
cols: Iterable[str] | None = None,
|
|
305
|
+
index_base: int = 1,
|
|
306
|
+
name_strategy: str = "base_first",
|
|
307
|
+
base_name: str | None = None,
|
|
308
|
+
agg_method: Literal["mean", "median", "sum", "min", "max"] = "mean",
|
|
309
|
+
override_names: Sequence[str | None] | None = None,
|
|
310
|
+
):
|
|
311
|
+
self._require_attributes(
|
|
312
|
+
"df",
|
|
313
|
+
"grouped_entries",
|
|
314
|
+
"wavelength_cols",
|
|
315
|
+
"name_col",
|
|
316
|
+
message="Run previous steps first.",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
use_cols = self._select_numeric_cols(self.df, cols or self.wavelength_cols)
|
|
320
|
+
name_col = self.name_col
|
|
321
|
+
|
|
322
|
+
if override_names is not None and len(override_names) != len(self.grouped_entries):
|
|
323
|
+
raise ValueError("override_names length must match number of groups.")
|
|
324
|
+
|
|
325
|
+
out_rows = []
|
|
326
|
+
for g_id, group in enumerate(self.grouped_entries, start=1):
|
|
327
|
+
if not group:
|
|
328
|
+
warnings.warn(f"Empty group at position {g_id}; skipping.", UserWarning)
|
|
329
|
+
continue
|
|
330
|
+
self._validate_group_indices(self.df, group, g_id)
|
|
331
|
+
row_idxs = self._row_indices(group)
|
|
332
|
+
|
|
333
|
+
override_name = None
|
|
334
|
+
if override_names:
|
|
335
|
+
override_name = override_names[g_id - 1]
|
|
336
|
+
|
|
337
|
+
if override_name:
|
|
338
|
+
sample_name = override_name
|
|
339
|
+
elif base_name is not None:
|
|
340
|
+
sample_name = self._generate_sample_name_with_min(base_name, group)
|
|
341
|
+
else:
|
|
342
|
+
sample_name = self._pick_sample_name(self.df, name_col, group, row_idxs, name_strategy)
|
|
343
|
+
|
|
344
|
+
averaged = self._aggregate_rows(self.df, row_idxs, use_cols, agg_method)
|
|
345
|
+
|
|
346
|
+
group_numbers = [str(e.number) for e in group if e.number is not None]
|
|
347
|
+
grouping_str = ",".join(group_numbers) if group_numbers else ""
|
|
348
|
+
|
|
349
|
+
averaged.insert(0, "row_indices", self._format_indices_display(row_idxs, index_base))
|
|
350
|
+
averaged.insert(0, "grouping", grouping_str)
|
|
351
|
+
averaged.insert(0, "name", sample_name)
|
|
352
|
+
|
|
353
|
+
out_rows.append(averaged)
|
|
354
|
+
|
|
355
|
+
self.grouped_df: pd.DataFrame = pd.concat(out_rows, ignore_index=True) if out_rows else pd.DataFrame()
|
|
356
|
+
return self
|
|
357
|
+
|
|
358
|
+
# ---------- Ungrouped summary ----------
|
|
359
|
+
@staticmethod
|
|
360
|
+
def ungrouped_entries_to_df(ungrouped: list["SVCDataProcessor.SigEntry"]) -> pd.DataFrame:
|
|
361
|
+
if not ungrouped:
|
|
362
|
+
return pd.DataFrame(columns=["index", "number", "base_name"])
|
|
363
|
+
return pd.DataFrame([{"index": e.index, "number": e.number, "base_name": e.base_name} for e in ungrouped])
|
|
364
|
+
|
|
365
|
+
def ungrouped_as_df(self):
|
|
366
|
+
self.ungrouped_df = self.ungrouped_entries_to_df(getattr(self, "ungrouped_entries", []) or [])
|
|
367
|
+
return self
|
|
368
|
+
|
|
369
|
+
# ---------- Combine grouped + ungrouped ----------
|
|
370
|
+
def concat_grouped_and_ungrouped(
|
|
371
|
+
self,
|
|
372
|
+
*,
|
|
373
|
+
ungrouped_mode: Literal["raw", "empty"] = "raw",
|
|
374
|
+
index_base: int = 1
|
|
375
|
+
):
|
|
376
|
+
"""Stack self.grouped_df with ungrouped entries (raw spectra or empty spectral cells)."""
|
|
377
|
+
self._require_attributes("grouped_df", message="Call average_groups first.")
|
|
378
|
+
self._require_attributes(
|
|
379
|
+
"ungrouped_entries",
|
|
380
|
+
message="Call group_by first to populate ungrouped_entries.",
|
|
381
|
+
)
|
|
382
|
+
self._require_attributes(
|
|
383
|
+
"df",
|
|
384
|
+
"wavelength_cols",
|
|
385
|
+
"name_col",
|
|
386
|
+
message="Ensure load_csv/split_columns ran.",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
grouped = self.grouped_df.copy()
|
|
390
|
+
|
|
391
|
+
rows = []
|
|
392
|
+
for e in (self.ungrouped_entries or []):
|
|
393
|
+
row = {col: None for col in grouped.columns}
|
|
394
|
+
row["name"] = e.base_name or str(self.df.iloc[e.index][self.name_col])
|
|
395
|
+
row["row_indices"] = self._format_indices_display([e.index], index_base)
|
|
396
|
+
row["grouping"] = str(e.number) if e.number is not None else ""
|
|
397
|
+
|
|
398
|
+
if ungrouped_mode == "raw":
|
|
399
|
+
for col in grouped.columns:
|
|
400
|
+
if col in ("name", "row_indices", "grouping"):
|
|
401
|
+
continue
|
|
402
|
+
if col in self.df.columns:
|
|
403
|
+
row[col] = self.df.iloc[e.index][col]
|
|
404
|
+
rows.append(row)
|
|
405
|
+
|
|
406
|
+
ungrouped_df_aligned = pd.DataFrame(rows, columns=grouped.columns) if rows else pd.DataFrame(columns=grouped.columns)
|
|
407
|
+
self.final_df = pd.concat([grouped, ungrouped_df_aligned], ignore_index=True)
|
|
408
|
+
return self
|
|
409
|
+
|
|
410
|
+
# ---------- Pretty prints ----------
|
|
411
|
+
def debug_print_groups(self):
|
|
412
|
+
"""Print groups and ungrouped entries with clear headers and spacing."""
|
|
413
|
+
self._require_attributes("grouped_entries", message="Call group_by first.")
|
|
414
|
+
|
|
415
|
+
print("#### Group Entries ####\n")
|
|
416
|
+
print("Number of groups:", len(self.grouped_entries), "\n")
|
|
417
|
+
|
|
418
|
+
for gi, group in enumerate(self.grouped_entries, start=1):
|
|
419
|
+
print(f"##### Group {gi} #####")
|
|
420
|
+
if not group:
|
|
421
|
+
print(" (empty)\n")
|
|
422
|
+
continue
|
|
423
|
+
for e in group:
|
|
424
|
+
print(f" index={e.index}, number={e.number}, base_name={e.base_name}")
|
|
425
|
+
print()
|
|
426
|
+
|
|
427
|
+
print("##### Ungrouped Entries #####")
|
|
428
|
+
if getattr(self, "ungrouped_entries", None):
|
|
429
|
+
for e in self.ungrouped_entries:
|
|
430
|
+
print(f" index={e.index}, number={e.number}, base_name={e.base_name}")
|
|
431
|
+
else:
|
|
432
|
+
print(" None")
|
|
433
|
+
print()
|
|
434
|
+
|
|
435
|
+
# ---------- Save ----------
|
|
436
|
+
def save_csv(self, out_path: str):
|
|
437
|
+
self._require_attributes("grouped_df", message="Call average_groups first.")
|
|
438
|
+
self.grouped_df.to_csv(out_path, index=False)
|
|
439
|
+
return self
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class SigSpectraAverager:
|
|
443
|
+
"""Facade around SVCDataProcessor that normalizes sample names and averages spectra."""
|
|
444
|
+
|
|
445
|
+
def __init__(self, df: pd.DataFrame, sample_col: str = "sample_name"):
|
|
446
|
+
self.sample_col = sample_col
|
|
447
|
+
|
|
448
|
+
working_df = df.copy()
|
|
449
|
+
if sample_col != "name":
|
|
450
|
+
if sample_col not in working_df.columns:
|
|
451
|
+
raise KeyError(f"Column '{sample_col}' not found in DataFrame.")
|
|
452
|
+
working_df = working_df.rename(columns={sample_col: "name"})
|
|
453
|
+
|
|
454
|
+
self.dataframe = SVCDataProcessor.normalize_sample_column(working_df, column="name", inplace=False)
|
|
455
|
+
self.processor = SVCDataProcessor()
|
|
456
|
+
self.processor.df = self.dataframe
|
|
457
|
+
self.processor.split_columns(name_col="name")
|
|
458
|
+
self.processor.extract_sig_entries()
|
|
459
|
+
|
|
460
|
+
@classmethod
|
|
461
|
+
def from_csv(cls, file_path: str, sample_col: str = "sample_name", **kwargs) -> "SigSpectraAverager":
|
|
462
|
+
df = pd.read_csv(file_path, **kwargs)
|
|
463
|
+
return cls(df, sample_col=sample_col)
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _normalize_groups(groups: Sequence[int | Iterable[int]]) -> list[dict]:
|
|
467
|
+
normalized: list[dict] = []
|
|
468
|
+
for g in groups:
|
|
469
|
+
custom_name: str | None = None
|
|
470
|
+
|
|
471
|
+
if isinstance(g, GroupSpec):
|
|
472
|
+
members_source = g.members
|
|
473
|
+
custom_name = g.name
|
|
474
|
+
elif hasattr(g, "_fields"):
|
|
475
|
+
custom_name = getattr(g, "name", None)
|
|
476
|
+
if hasattr(g, "members"):
|
|
477
|
+
members_source = getattr(g, "members")
|
|
478
|
+
else:
|
|
479
|
+
members_source = [getattr(g, field) for field in g._fields if field != "name"]
|
|
480
|
+
elif hasattr(g, "members") and isinstance(getattr(g, "members"), Iterable):
|
|
481
|
+
members_source = getattr(g, "members")
|
|
482
|
+
custom_name = getattr(g, "name", None)
|
|
483
|
+
elif isinstance(g, Iterable) and not isinstance(g, (str, bytes)):
|
|
484
|
+
members_source = g
|
|
485
|
+
custom_name = getattr(g, "name", None) if hasattr(g, "name") else None
|
|
486
|
+
else:
|
|
487
|
+
members_source = (g,)
|
|
488
|
+
|
|
489
|
+
if isinstance(members_source, Iterable) and not isinstance(members_source, (str, bytes)):
|
|
490
|
+
flattened: list[int] = []
|
|
491
|
+
for value in members_source:
|
|
492
|
+
if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
|
|
493
|
+
flattened.extend(int(v) for v in value)
|
|
494
|
+
else:
|
|
495
|
+
flattened.append(int(value))
|
|
496
|
+
values = tuple(flattened) if flattened else (int(members_source),)
|
|
497
|
+
else:
|
|
498
|
+
values = (int(members_source),)
|
|
499
|
+
|
|
500
|
+
normalized.append({"members": values, "name": custom_name})
|
|
501
|
+
return normalized
|
|
502
|
+
|
|
503
|
+
def aggregate(
|
|
504
|
+
self,
|
|
505
|
+
groups: Sequence[int | Iterable[int]],
|
|
506
|
+
method: Literal["mean", "median", "sum", "min", "max"] | None = "mean",
|
|
507
|
+
*,
|
|
508
|
+
index_base: int = 1,
|
|
509
|
+
name_strategy: str = "base_first",
|
|
510
|
+
base_name: str | None = None,
|
|
511
|
+
) -> pd.DataFrame:
|
|
512
|
+
normalized_groups = self._normalize_groups(groups)
|
|
513
|
+
member_sets = [spec["members"] for spec in normalized_groups]
|
|
514
|
+
custom_names = [spec["name"] for spec in normalized_groups]
|
|
515
|
+
|
|
516
|
+
self.processor.group_by(member_sets, by="number")
|
|
517
|
+
|
|
518
|
+
if method is None:
|
|
519
|
+
grouped_frames = []
|
|
520
|
+
for name_spec, group in zip(normalized_groups, self.processor.grouped_entries):
|
|
521
|
+
if not group:
|
|
522
|
+
continue
|
|
523
|
+
rows = [entry.index for entry in group]
|
|
524
|
+
subset = self.processor.df.iloc[rows].copy()
|
|
525
|
+
override_name = name_spec.get("name")
|
|
526
|
+
if override_name:
|
|
527
|
+
subset["name"] = override_name
|
|
528
|
+
grouped_frames.append(subset)
|
|
529
|
+
return pd.concat(grouped_frames, ignore_index=True) if grouped_frames else pd.DataFrame()
|
|
530
|
+
|
|
531
|
+
self.processor.average_groups(
|
|
532
|
+
cols=self.processor.wavelength_cols,
|
|
533
|
+
index_base=index_base,
|
|
534
|
+
name_strategy=name_strategy,
|
|
535
|
+
base_name=base_name,
|
|
536
|
+
agg_method=method,
|
|
537
|
+
override_names=custom_names,
|
|
538
|
+
)
|
|
539
|
+
return self.processor.grouped_df.copy()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def find_spectra_by_name(
|
|
543
|
+
dataframes: Sequence[pd.DataFrame],
|
|
544
|
+
search_key: str,
|
|
545
|
+
*,
|
|
546
|
+
name_column: str = "name",
|
|
547
|
+
case_sensitive: bool = False,
|
|
548
|
+
exact_match: bool = False,
|
|
549
|
+
) -> pd.DataFrame:
|
|
550
|
+
"""Search across multiple spectra DataFrames for rows whose name column matches search_key."""
|
|
551
|
+
if not search_key:
|
|
552
|
+
raise ValueError("search_key must be a non-empty string.")
|
|
553
|
+
|
|
554
|
+
results: list[pd.DataFrame] = []
|
|
555
|
+
for idx, df in enumerate(dataframes):
|
|
556
|
+
if name_column not in df.columns:
|
|
557
|
+
raise KeyError(f"DataFrame at position {idx} lacks the '{name_column}' column.")
|
|
558
|
+
|
|
559
|
+
names = df[name_column].astype(str)
|
|
560
|
+
if exact_match:
|
|
561
|
+
mask = names.eq(search_key) if case_sensitive else names.str.lower().eq(search_key.lower())
|
|
562
|
+
else:
|
|
563
|
+
mask = names.str.contains(
|
|
564
|
+
search_key if case_sensitive else re.escape(search_key),
|
|
565
|
+
case=case_sensitive,
|
|
566
|
+
regex=not exact_match,
|
|
567
|
+
na=False,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
subset = df.loc[mask]
|
|
571
|
+
if not subset.empty:
|
|
572
|
+
annotated = subset.copy()
|
|
573
|
+
annotated.insert(0, "_source_index", idx)
|
|
574
|
+
results.append(annotated)
|
|
575
|
+
|
|
576
|
+
if not results:
|
|
577
|
+
if dataframes:
|
|
578
|
+
base_cols = list(dataframes[0].columns)
|
|
579
|
+
return pd.DataFrame(columns=["_source_index"] + base_cols)
|
|
580
|
+
return pd.DataFrame()
|
|
581
|
+
|
|
582
|
+
return pd.concat(results, ignore_index=True)
|