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 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)