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.
- operandomerge/__init__.py +26 -0
- operandomerge/cli.py +84 -0
- operandomerge/config.py +94 -0
- operandomerge/controller.py +63 -0
- operandomerge/export.py +66 -0
- operandomerge/gui.py +506 -0
- operandomerge/io.py +27 -0
- operandomerge/merge.py +252 -0
- operandomerge/models.py +194 -0
- operandomerge/py.typed +1 -0
- operandomerge/qc.py +73 -0
- operandomerge/service.py +20 -0
- operandomerge/timeline.py +141 -0
- operandomerge-0.1.1.dist-info/METADATA +244 -0
- operandomerge-0.1.1.dist-info/RECORD +18 -0
- operandomerge-0.1.1.dist-info/WHEEL +4 -0
- operandomerge-0.1.1.dist-info/entry_points.txt +3 -0
- operandomerge-0.1.1.dist-info/licenses/LICENSE +22 -0
|
@@ -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())
|
operandomerge/config.py
ADDED
|
@@ -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)
|
operandomerge/export.py
ADDED
|
@@ -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
|