operandomerge 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.
@@ -0,0 +1,26 @@
1
+ """OperandoMerge public API."""
2
+
3
+ from operandomerge.models import (
4
+ AlignmentConfig,
5
+ AlignmentMethod,
6
+ ChannelConfig,
7
+ DatasetConfig,
8
+ DataType,
9
+ DelayConfig,
10
+ MergeConfig,
11
+ TimeRepresentation,
12
+ )
13
+ from operandomerge.service import MergeService
14
+
15
+ __all__ = [
16
+ "AlignmentConfig",
17
+ "AlignmentMethod",
18
+ "ChannelConfig",
19
+ "DataType",
20
+ "DatasetConfig",
21
+ "DelayConfig",
22
+ "MergeConfig",
23
+ "MergeService",
24
+ "TimeRepresentation",
25
+ ]
26
+ __version__ = "0.1.1"
operandomerge/cli.py ADDED
@@ -0,0 +1,84 @@
1
+ """Command-line interface for inspectable, reproducible merges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ import matplotlib.pyplot as plt
12
+
13
+ from operandomerge import __version__
14
+ from operandomerge.config import load_config
15
+ from operandomerge.export import export_csv_bundle, export_excel, plot_alignment
16
+ from operandomerge.io import inspect_columns
17
+ from operandomerge.service import MergeService
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(prog="operandomerge", description=__doc__)
22
+ parser.add_argument("--version", action="version", version=f"OperandoMerge {__version__}")
23
+ subparsers = parser.add_subparsers(dest="command", required=True)
24
+
25
+ inspect_parser = subparsers.add_parser("inspect", help="List columns in a CSV/XLSX file")
26
+ inspect_parser.add_argument("input", type=Path)
27
+ inspect_parser.add_argument("--sheet", default=0)
28
+
29
+ merge_parser = subparsers.add_parser("merge", help="Run a merge from a JSON configuration")
30
+ merge_parser.add_argument("config", type=Path)
31
+ output = merge_parser.add_mutually_exclusive_group(required=True)
32
+ output.add_argument("--excel", type=Path, help="Write a multi-sheet XLSX report")
33
+ output.add_argument("--csv-dir", type=Path, help="Write a CSV report bundle")
34
+ merge_parser.add_argument("--plot", type=Path, help="Write an alignment-preview PNG/SVG/PDF")
35
+ merge_parser.add_argument("--fail-on-qc-error", action="store_true")
36
+
37
+ subparsers.add_parser("gui", help="Launch the desktop GUI")
38
+ return parser
39
+
40
+
41
+ def main(argv: Sequence[str] | None = None) -> int:
42
+ args = build_parser().parse_args(argv)
43
+ try:
44
+ if args.command == "inspect":
45
+ columns = inspect_columns(args.input, _sheet_value(args.sheet))
46
+ print(json.dumps(columns, indent=2))
47
+ return 0
48
+ if args.command == "gui":
49
+ from operandomerge.gui import main as gui_main
50
+
51
+ gui_main()
52
+ return 0
53
+ datasets, merge_config = load_config(args.config)
54
+ result = MergeService().run(datasets, merge_config)
55
+ if args.excel:
56
+ export_excel(result, args.excel)
57
+ destination = args.excel
58
+ else:
59
+ export_csv_bundle(result, args.csv_dir)
60
+ destination = args.csv_dir
61
+ if args.plot:
62
+ figure = plot_alignment(result, args.plot)
63
+ plt.close(figure)
64
+ errors = sum(issue.severity == "error" for issue in result.qc)
65
+ warnings = sum(issue.severity == "warning" for issue in result.qc)
66
+ print(
67
+ f"Merged {len(datasets)} dataset(s), {len(result.merged)} timeline row(s); "
68
+ f"QC: {errors} error(s), {warnings} warning(s); output: {destination}"
69
+ )
70
+ return 2 if args.fail_on_qc_error and errors else 0
71
+ except (FileNotFoundError, KeyError, TypeError, ValueError) as error:
72
+ print(f"operandomerge: error: {error}", file=sys.stderr)
73
+ return 2
74
+
75
+
76
+ def _sheet_value(value: object) -> str | int | None:
77
+ if value is None:
78
+ return None
79
+ text = str(value)
80
+ return int(text) if text.isdigit() else text
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
@@ -0,0 +1,94 @@
1
+ """JSON configuration parsing and serialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from operandomerge.models import (
11
+ AlignmentConfig,
12
+ AlignmentMethod,
13
+ ChannelConfig,
14
+ DatasetConfig,
15
+ DataType,
16
+ DelayConfig,
17
+ MergeConfig,
18
+ TimeRepresentation,
19
+ )
20
+
21
+
22
+ def _dataset_from_dict(raw: dict[str, Any], base: Path) -> DatasetConfig:
23
+ source_path = Path(str(raw["path"])).expanduser()
24
+ if not source_path.is_absolute():
25
+ source_path = (base / source_path).resolve()
26
+ alignment_raw = raw.get("alignment", {})
27
+ delay_raw = raw.get("delay", {})
28
+ return DatasetConfig(
29
+ path=source_path,
30
+ name=raw.get("name"),
31
+ sheet_name=raw.get("sheet_name", 0),
32
+ time_column=str(raw["time_column"]),
33
+ time_representation=TimeRepresentation(raw["time_representation"]),
34
+ channels=tuple(
35
+ ChannelConfig(
36
+ source_column=str(channel["source_column"]),
37
+ data_type=DataType(channel.get("data_type", "continuous")),
38
+ output_name=channel.get("output_name"),
39
+ )
40
+ for channel in raw["channels"]
41
+ ),
42
+ alignment=AlignmentConfig(
43
+ method=AlignmentMethod(alignment_raw.get("method", "elapsed")),
44
+ manual_offset_s=float(alignment_raw.get("manual_offset_s", 0.0)),
45
+ source_event_time_s=_optional_float(alignment_raw.get("source_event_time_s")),
46
+ target_event_time_s=_optional_float(alignment_raw.get("target_event_time_s")),
47
+ ),
48
+ delay=DelayConfig(
49
+ manual_s=float(delay_raw.get("manual_s", 0.0)),
50
+ sampling_s=float(delay_raw.get("sampling_s", 0.0)),
51
+ transport_s=float(delay_raw.get("transport_s", 0.0)),
52
+ dead_volume_s=float(delay_raw.get("dead_volume_s", 0.0)),
53
+ analysis_s=float(delay_raw.get("analysis_s", 0.0)),
54
+ ),
55
+ )
56
+
57
+
58
+ def _optional_float(value: Any) -> float | None:
59
+ return None if value in {None, ""} else float(value)
60
+
61
+
62
+ def load_config(path: Path) -> tuple[list[DatasetConfig], MergeConfig]:
63
+ raw = json.loads(path.read_text(encoding="utf-8"))
64
+ if not isinstance(raw, dict) or not isinstance(raw.get("datasets"), list):
65
+ raise ValueError("Configuration must contain a 'datasets' list")
66
+ datasets = [_dataset_from_dict(item, path.parent) for item in raw["datasets"]]
67
+ merge_raw = raw.get("merge", {})
68
+ merge = MergeConfig(
69
+ timeline=merge_raw.get("timeline", "union"),
70
+ reference_dataset=merge_raw.get("reference_dataset"),
71
+ experiment_origin=merge_raw.get("experiment_origin"),
72
+ continuous_method=merge_raw.get("continuous_method", "linear"),
73
+ stepwise_method=merge_raw.get("stepwise_method", "previous"),
74
+ exact_tolerance_s=float(merge_raw.get("exact_tolerance_s", 1e-9)),
75
+ )
76
+ return datasets, merge
77
+
78
+
79
+ def config_as_dict(datasets: list[DatasetConfig], merge: MergeConfig) -> dict[str, Any]:
80
+ dataset_items: list[dict[str, Any]] = []
81
+ for dataset in datasets:
82
+ item = asdict(dataset)
83
+ item["path"] = str(dataset.path)
84
+ item["time_representation"] = dataset.time_representation.value
85
+ item["alignment"]["method"] = dataset.alignment.method.value
86
+ for channel_item, channel in zip(item["channels"], dataset.channels, strict=True):
87
+ channel_item["data_type"] = channel.data_type.value
88
+ dataset_items.append(item)
89
+ return {"datasets": dataset_items, "merge": asdict(merge)}
90
+
91
+
92
+ def save_config(datasets: list[DatasetConfig], merge: MergeConfig, path: Path) -> None:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ path.write_text(json.dumps(config_as_dict(datasets, merge), indent=2), encoding="utf-8")
@@ -0,0 +1,63 @@
1
+ """GUI application state without a desktop-toolkit dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from operandomerge.config import load_config, save_config
9
+ from operandomerge.export import export_excel
10
+ from operandomerge.models import DatasetConfig, MergeConfig, MergeResult
11
+ from operandomerge.service import MergeService
12
+
13
+
14
+ class MergeRunner(Protocol):
15
+ def run(self, datasets: list[DatasetConfig], merge: MergeConfig) -> MergeResult: ...
16
+
17
+
18
+ def merge_config_from_fields(
19
+ timeline: str,
20
+ reference_dataset: str,
21
+ experiment_origin: str,
22
+ continuous_method: str,
23
+ stepwise_method: str,
24
+ exact_tolerance_s: str,
25
+ ) -> MergeConfig:
26
+ """Convert GUI text controls into the same validated core model used by JSON."""
27
+
28
+ config = MergeConfig(
29
+ timeline=timeline,
30
+ reference_dataset=reference_dataset or None,
31
+ experiment_origin=experiment_origin or None,
32
+ continuous_method=continuous_method,
33
+ stepwise_method=stepwise_method,
34
+ exact_tolerance_s=float(exact_tolerance_s),
35
+ )
36
+ config.validate()
37
+ return config
38
+
39
+
40
+ class GuiController:
41
+ """Testable GUI application layer; scientific work remains in MergeService."""
42
+
43
+ def __init__(self, service: MergeRunner | None = None) -> None:
44
+ self.service = service or MergeService()
45
+ self.datasets: list[DatasetConfig] = []
46
+ self.merge_config = MergeConfig()
47
+ self.result: MergeResult | None = None
48
+
49
+ def run_merge(self) -> MergeResult:
50
+ self.result = self.service.run(self.datasets, self.merge_config)
51
+ return self.result
52
+
53
+ def export(self, path: Path) -> None:
54
+ if self.result is None:
55
+ raise ValueError("Run a merge before exporting")
56
+ export_excel(self.result, path)
57
+
58
+ def load(self, path: Path) -> None:
59
+ self.datasets, self.merge_config = load_config(path)
60
+ self.result = None
61
+
62
+ def save(self, path: Path) -> None:
63
+ save_config(self.datasets, self.merge_config, path)
@@ -0,0 +1,66 @@
1
+ """Reproducible CSV/Excel and figure exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import matplotlib.pyplot as plt
10
+ import pandas as pd
11
+ from matplotlib.figure import Figure
12
+
13
+ from operandomerge.models import MergeResult
14
+
15
+
16
+ def qc_frame(result: MergeResult) -> pd.DataFrame:
17
+ return pd.DataFrame(
18
+ [issue.as_dict() for issue in result.qc],
19
+ columns=["severity", "code", "dataset", "channel", "message", "row"],
20
+ )
21
+
22
+
23
+ def configuration_frame(configuration: dict[str, Any]) -> pd.DataFrame:
24
+ return pd.DataFrame(
25
+ [
26
+ {"setting": key, "value": json.dumps(value) if value is not None else ""}
27
+ for key, value in configuration.items()
28
+ ]
29
+ )
30
+
31
+
32
+ def export_excel(result: MergeResult, path: Path) -> None:
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ with pd.ExcelWriter(path, engine="openpyxl") as writer:
35
+ result.merged.to_excel(writer, sheet_name="merged", index=False)
36
+ result.metadata.to_excel(writer, sheet_name="metadata", index=False)
37
+ result.provenance.to_excel(writer, sheet_name="provenance", index=False)
38
+ qc_frame(result).to_excel(writer, sheet_name="qc", index=False)
39
+ configuration_frame(result.configuration).to_excel(
40
+ writer, sheet_name="configuration", index=False
41
+ )
42
+
43
+
44
+ def export_csv_bundle(result: MergeResult, directory: Path) -> None:
45
+ directory.mkdir(parents=True, exist_ok=True)
46
+ result.merged.to_csv(directory / "merged.csv", index=False)
47
+ result.metadata.to_csv(directory / "metadata.csv", index=False)
48
+ result.provenance.to_csv(directory / "provenance.csv", index=False)
49
+ qc_frame(result).to_csv(directory / "qc.csv", index=False)
50
+ configuration_frame(result.configuration).to_csv(directory / "configuration.csv", index=False)
51
+
52
+
53
+ def plot_alignment(result: MergeResult, path: Path | None = None) -> Figure:
54
+ figure, axis = plt.subplots(figsize=(9, 5), constrained_layout=True)
55
+ time = result.merged["experiment_time_s"]
56
+ for column in result.merged.columns[1:]:
57
+ axis.plot(time, result.merged[column], marker=".", linewidth=1, label=column)
58
+ axis.set_xlabel("Experiment time / s")
59
+ axis.set_ylabel("Signal (source units)")
60
+ axis.set_title("OperandoMerge alignment preview")
61
+ if len(result.merged.columns) > 1:
62
+ axis.legend(loc="best", fontsize="small")
63
+ if path is not None:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ figure.savefig(path, dpi=160)
66
+ return figure