copra2 2.0.0rc3__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.
copra/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """COPRA — Constructing Proxy Records from Age Models.
2
+
3
+ Python/Qt6 reimplementation of the original MATLAB/Octave toolbox. The
4
+ scientifically relevant code lives in :mod:`copra.core` and has no Qt or
5
+ matplotlib dependency; plotting and GUI layers build on top of it.
6
+ """
7
+
8
+ __version__ = "2.0.0rc3"
copra/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m copra`` as an alias for the ``copra-cli`` entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
copra/cli.py ADDED
@@ -0,0 +1,595 @@
1
+ """Command-line entry point (ports the scriptable behaviour of ``copra.m``).
2
+
3
+ The ``copra-cli`` console script runs the whole non-interactive pipeline --
4
+ load -> classify reversals -> (config-driven) reversal treatment -> hiatus
5
+ handling -> Monte Carlo age model -> resample -> save results/session/log, and
6
+ optionally save plots. All choices that ``copra.m`` collected interactively are
7
+ supplied as CLI options or a TOML config file.
8
+
9
+ Subcommands
10
+ -----------
11
+ run Run the full pipeline and write outputs.
12
+ check Load the data and report reversals / hiatus candidates (no MC).
13
+ reproduce Re-run a saved JSON session (same inputs + seed => identical output).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ from .core.hiatus import detect_hiatus_candidates
24
+ from .core.io import CopraIOError, load_dating_table, load_proxy_record
25
+ from .core.logging_ import (
26
+ load_json_session,
27
+ reproduce,
28
+ run_session,
29
+ save_results,
30
+ )
31
+ from .core.model import CopraSession, ReversalType
32
+ from .core.reversals import (
33
+ ERROR_INCREASE_FACTOR,
34
+ classify_reversals,
35
+ treat_reversals,
36
+ )
37
+
38
+
39
+ def _error_factors_from_list(ids) -> dict[int, float]:
40
+ """Map repeated --increase-error ids to per-point factors (x1.1 per repeat)."""
41
+ counts = Counter(int(x) for x in (ids or []))
42
+ return {pid: ERROR_INCREASE_FACTOR**n for pid, n in counts.items()}
43
+
44
+
45
+ _INTERP_CHOICES = ("linear", "pchip", "spline")
46
+
47
+ # Keys read from a TOML ``[copra]`` table / defaults for a run.
48
+ _DEFAULTS: dict = {
49
+ "M": 2000,
50
+ "interp": "pchip",
51
+ "output_dir": "output",
52
+ "confbnds_proxy": 95.0,
53
+ "confbnds_age": 95.0,
54
+ "sample_size": None,
55
+ "auto_hiatus": False,
56
+ "plots": False,
57
+ }
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # Config handling
62
+ # --------------------------------------------------------------------------- #
63
+ def _load_config(path: str) -> dict:
64
+ import tomllib
65
+
66
+ with open(path, "rb") as fh:
67
+ data = tomllib.load(fh)
68
+ return data.get("copra", data)
69
+
70
+
71
+ def _resolve_run_config(args: argparse.Namespace) -> dict:
72
+ """Merge defaults, an optional TOML config, and explicit CLI options."""
73
+ cfg = dict(_DEFAULTS)
74
+ if args.config:
75
+ cfg.update(_load_config(args.config))
76
+ scalar_keys = (
77
+ "dating",
78
+ "proxy",
79
+ "layercount",
80
+ "M",
81
+ "interp",
82
+ "seed",
83
+ "samplename",
84
+ "proxyname",
85
+ "output_dir",
86
+ "confbnds_proxy",
87
+ "confbnds_age",
88
+ "sample_size",
89
+ "hiatus",
90
+ "remove_points",
91
+ "increase_error",
92
+ )
93
+ for key in scalar_keys:
94
+ val = getattr(args, key, None)
95
+ if val is not None:
96
+ cfg[key] = val
97
+ for flag in ("auto_hiatus", "plots"):
98
+ cfg[flag] = bool(cfg.get(flag, False)) or bool(getattr(args, flag, False))
99
+ return cfg
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Reporting helpers
104
+ # --------------------------------------------------------------------------- #
105
+ def _report_reversals(dating) -> None:
106
+ report = classify_reversals(dating)
107
+ if not report.has_reversal:
108
+ print("Reversals: none.")
109
+ return
110
+ print(
111
+ f"Reversals found: tractable around point(s) {report.tractable_points}, "
112
+ f"non-tractable around point(s) {report.nontractable_points}."
113
+ )
114
+ if any(t == ReversalType.NONTRACTABLE for t in report.rev_type):
115
+ print(
116
+ " Note: non-tractable reversals should usually be removed "
117
+ "(--remove-points) before running."
118
+ )
119
+
120
+
121
+ # --------------------------------------------------------------------------- #
122
+ # Subcommands
123
+ # --------------------------------------------------------------------------- #
124
+ def cmd_run(args: argparse.Namespace) -> int:
125
+ cfg = _resolve_run_config(args)
126
+ if not cfg.get("dating") or not cfg.get("proxy"):
127
+ print("error: --dating and --proxy are required.", file=sys.stderr)
128
+ return 2
129
+
130
+ remove_points = [int(x) for x in (cfg.get("remove_points") or [])]
131
+ error_factors = _error_factors_from_list(cfg.get("increase_error"))
132
+ samplename = cfg.get("samplename") or Path(cfg["dating"]).stem
133
+
134
+ print(f"Loading dating table: {cfg['dating']}")
135
+ _report_reversals(load_dating_table(cfg["dating"]))
136
+
137
+ hia = [float(x) for x in (cfg.get("hiatus") or [])]
138
+ if cfg["auto_hiatus"] and not hia:
139
+ dt = load_dating_table(cfg["dating"])
140
+ if remove_points or error_factors:
141
+ dt = treat_reversals(dt, remove_points, error_factors=error_factors)
142
+ hia = [float(x) for x in detect_hiatus_candidates(dt).depth]
143
+ print(f"Auto-detected hiatus depth(s): {hia if hia else 'none'}")
144
+
145
+ session = CopraSession(
146
+ dating_path=str(cfg["dating"]),
147
+ proxy_path=str(cfg["proxy"]),
148
+ layercount_path=str(cfg["layercount"]) if cfg.get("layercount") else None,
149
+ M=int(cfg["M"]),
150
+ interp_method=cfg["interp"],
151
+ hia_depth=hia,
152
+ remove_points=remove_points,
153
+ error_factors=error_factors,
154
+ confbnds_prxy=float(cfg["confbnds_proxy"]),
155
+ confbnds_age=float(cfg["confbnds_age"]),
156
+ samplename=samplename,
157
+ proxyname=cfg.get("proxyname") or "\\delta^{18}O",
158
+ sample_size=(float(cfg["sample_size"]) if cfg.get("sample_size") else None),
159
+ seed=int(cfg["seed"]) if cfg.get("seed") is not None else None,
160
+ )
161
+
162
+ print(
163
+ f"Running MC age model (M={session.M}, interp={session.interp_method}"
164
+ + (f", hiatus={hia}" if hia else "")
165
+ + (", layer counting" if session.layercount_path else "")
166
+ + ") ..."
167
+ )
168
+ run_session(session)
169
+ print(
170
+ f"Done: seed={session.seed}, redraws={session.ensemble.n_redraws}, "
171
+ f"{session.ensemble.ages.shape[0]} depths x {session.M} realizations."
172
+ )
173
+
174
+ paths = save_results(session, cfg["output_dir"])
175
+ for kind, path in paths.items():
176
+ print(f" wrote {kind}: {path}")
177
+
178
+ if cfg["plots"]:
179
+ _save_plots(session, Path(cfg["output_dir"]), samplename)
180
+ return 0
181
+
182
+
183
+ def _save_plots(session: CopraSession, output_dir: Path, samplename: str) -> None:
184
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
185
+
186
+ from .plotting import plot_agemodel, plot_proxy_record
187
+
188
+ reversals = classify_reversals(session.dating)
189
+ figures = {
190
+ "agemodel": plot_agemodel(session, mode="mean", reversals=reversals),
191
+ "proxy": plot_proxy_record(session, mode="mean"),
192
+ }
193
+ for name, fig in figures.items():
194
+ FigureCanvasAgg(fig)
195
+ path = output_dir / f"{samplename}_{name}.png"
196
+ fig.savefig(path, dpi=120, bbox_inches="tight")
197
+ print(f" wrote plot: {path}")
198
+
199
+
200
+ def _save_figure(fig, path: Path) -> Path:
201
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
202
+
203
+ FigureCanvasAgg(fig)
204
+ fig.savefig(path, dpi=120, bbox_inches="tight")
205
+ return path
206
+
207
+
208
+ def _try_open(path: Path) -> None:
209
+ """Best-effort: open a file in the OS default viewer (never raises)."""
210
+ import subprocess
211
+
212
+ try:
213
+ if sys.platform == "darwin":
214
+ subprocess.Popen(
215
+ ["open", str(path)],
216
+ stdout=subprocess.DEVNULL,
217
+ stderr=subprocess.DEVNULL,
218
+ )
219
+ elif sys.platform.startswith("win"):
220
+ import os
221
+
222
+ os.startfile(str(path)) # type: ignore[attr-defined]
223
+ else:
224
+ subprocess.Popen(
225
+ ["xdg-open", str(path)],
226
+ stdout=subprocess.DEVNULL,
227
+ stderr=subprocess.DEVNULL,
228
+ )
229
+ except Exception:
230
+ pass
231
+
232
+
233
+ def _default_preview(fig, name: str) -> None:
234
+ """Save a preview figure to a temp PNG, report the path, and try to open it."""
235
+ import tempfile
236
+
237
+ fd, path_str = tempfile.mkstemp(prefix=f"copra_{name}_", suffix=".png")
238
+ import os
239
+
240
+ os.close(fd)
241
+ path = Path(path_str)
242
+ _save_figure(fig, path)
243
+ print(f" preview ({name}) saved to: {path}")
244
+ _try_open(path)
245
+
246
+
247
+ def cmd_check(args: argparse.Namespace) -> int:
248
+ dating = load_dating_table(args.dating)
249
+ d = dating.depth
250
+ print(f"Dating points: {len(dating)} (depth {d[0]:g}..{d[-1]:g})")
251
+ if args.proxy:
252
+ proxy = load_proxy_record(args.proxy)
253
+ p = proxy.depth
254
+ print(f"Proxy samples: {len(proxy)} (depth {p[0]:g}..{p[-1]:g})")
255
+ report = classify_reversals(dating)
256
+ _report_reversals(dating)
257
+ cand = detect_hiatus_candidates(dating)
258
+ if cand.any:
259
+ print(
260
+ f"Hiatus candidate depth(s): {[round(float(x), 2) for x in cand.depth]} "
261
+ f"(after dating point(s) {cand.after_points})"
262
+ )
263
+ else:
264
+ print("Hiatus candidates: none.")
265
+
266
+ if args.plots:
267
+ from .plotting import plot_dating_reversals, plot_hiatus_candidates
268
+
269
+ proxy = load_proxy_record(args.proxy) if args.proxy else None
270
+ out = Path(args.output_dir) if args.output_dir else Path(".")
271
+ out.mkdir(parents=True, exist_ok=True)
272
+ stem = Path(args.dating).stem
273
+ rev_png = _save_figure(
274
+ plot_dating_reversals(dating, report, proxy=proxy, samplename=stem),
275
+ out / f"{stem}_reversals.png",
276
+ )
277
+ hia_png = _save_figure(
278
+ plot_hiatus_candidates(dating, cand, proxy=proxy, samplename=stem),
279
+ out / f"{stem}_hiatus.png",
280
+ )
281
+ print(f" wrote plot: {rev_png}")
282
+ print(f" wrote plot: {hia_png}")
283
+ return 0
284
+
285
+
286
+ def cmd_reproduce(args: argparse.Namespace) -> int:
287
+ if args.output_dir:
288
+ session = load_json_session(args.session)
289
+ run_session(session)
290
+ print(
291
+ f"Reproduced: seed={session.seed}, M={session.M}, "
292
+ f"ages={session.ensemble.ages.shape}"
293
+ )
294
+ paths = save_results(session, args.output_dir)
295
+ for kind, path in paths.items():
296
+ print(f" wrote {kind}: {path}")
297
+ else:
298
+ ensemble = reproduce(args.session)
299
+ print(
300
+ f"Reproduced: seed={ensemble.seed}, ages={ensemble.ages.shape}, "
301
+ f"redraws={ensemble.n_redraws}"
302
+ )
303
+ return 0
304
+
305
+
306
+ # --------------------------------------------------------------------------- #
307
+ # Interactive mode (mirrors the guided copra.m workflow)
308
+ # --------------------------------------------------------------------------- #
309
+ def _ask(prompt, default, input_fn, output_fn):
310
+ suffix = f" [{default}]" if default not in ("", None) else ""
311
+ ans = input_fn(f"{prompt}{suffix}: ").strip()
312
+ return ans if ans else ("" if default is None else str(default))
313
+
314
+
315
+ def _ask_yesno(prompt, default, input_fn, output_fn):
316
+ ans = input_fn(f"{prompt} [{'Y/n' if default else 'y/N'}]: ").strip().lower()
317
+ if not ans:
318
+ return default
319
+ return ans.startswith(("y", "j"))
320
+
321
+
322
+ def _ask_number(prompt, default, cast, input_fn, output_fn):
323
+ while True:
324
+ ans = input_fn(f"{prompt} [{default}]: ").strip()
325
+ if not ans:
326
+ return default
327
+ try:
328
+ return cast(ans)
329
+ except ValueError:
330
+ output_fn(f" Please enter a valid {cast.__name__}.")
331
+
332
+
333
+ def _ask_choice(prompt, choices, default, input_fn, output_fn):
334
+ while True:
335
+ ans = _ask(f"{prompt} ({'/'.join(choices)})", default, input_fn, output_fn)
336
+ if ans in choices:
337
+ return ans
338
+ output_fn(f" Choose one of: {', '.join(choices)}.")
339
+
340
+
341
+ def _ask_existing_file(prompt, input_fn, output_fn):
342
+ while True:
343
+ ans = input_fn(f"{prompt} (blank to cancel): ").strip()
344
+ if not ans:
345
+ return None
346
+ if Path(ans).is_file():
347
+ return ans
348
+ output_fn(f" File not found: {ans}")
349
+
350
+
351
+ _AUTO_PREVIEW = object() # sentinel: use the default preview handler
352
+
353
+
354
+ def run_interactive(
355
+ input_fn=input,
356
+ output_fn=print,
357
+ default_output_dir="output",
358
+ preview_fn=_AUTO_PREVIEW,
359
+ ) -> int:
360
+ """Guided, prompt-driven run mirroring the MATLAB ``copra`` workflow.
361
+
362
+ ``input_fn``/``output_fn`` are injectable so the flow can be unit-tested with
363
+ scripted input. ``preview_fn(figure, name)`` displays the reversal and hiatus
364
+ review plots before the corresponding prompts; it defaults to
365
+ :func:`_default_preview` (saves a PNG and best-effort opens it) and can be set
366
+ to ``None`` to skip previews. Produces the same outputs as ``copra-cli run``
367
+ and records the choices in the JSON session, so the run stays reproducible.
368
+ """
369
+ p = output_fn
370
+ if preview_fn is _AUTO_PREVIEW:
371
+ preview_fn = _default_preview
372
+ p("COPRA — interactive age-depth modelling. Press Enter to accept [defaults].")
373
+
374
+ dating = _ask_existing_file("Dating-table file", input_fn, p)
375
+ if dating is None:
376
+ p("Cancelled.")
377
+ return 1
378
+ proxy = _ask_existing_file("Proxy-record file", input_fn, p)
379
+ if proxy is None:
380
+ p("Cancelled.")
381
+ return 1
382
+
383
+ layercount = None
384
+ if _ask_yesno("Provide layer-count data?", False, input_fn, p):
385
+ layercount = _ask_existing_file("Layer-count file", input_fn, p)
386
+
387
+ interp = _ask_choice("Interpolation method", _INTERP_CHOICES, "pchip", input_fn, p)
388
+ M = _ask_number("Number of MC realizations", 2000, int, input_fn, p)
389
+ confp = _ask_number("Proxy confidence interval (%)", 95.0, float, input_fn, p)
390
+ confa = _ask_number("Age confidence interval (%)", 95.0, float, input_fn, p)
391
+ seed_str = _ask("Random seed (blank = auto)", "", input_fn, p)
392
+ seed = int(seed_str) if seed_str else None
393
+ samplename = _ask("Sample name", Path(dating).stem, input_fn, p)
394
+ proxyname = _ask("Proxy name", "\\delta^{18}O", input_fn, p)
395
+
396
+ # Reversal review and (optional) treatment.
397
+ dating_tbl = load_dating_table(dating)
398
+ proxy_rec = load_proxy_record(proxy) if preview_fn is not None else None
399
+ report = classify_reversals(dating_tbl)
400
+ remove_points: list[int] = []
401
+ increase_ids: list[int] = []
402
+ if report.has_reversal:
403
+ p(
404
+ f"Reversals: tractable {report.tractable_points}, "
405
+ f"non-tractable {report.nontractable_points}."
406
+ )
407
+ if preview_fn is not None:
408
+ from .plotting import plot_dating_reversals
409
+
410
+ preview_fn(
411
+ plot_dating_reversals(
412
+ dating_tbl, report, proxy=proxy_rec, samplename=samplename
413
+ ),
414
+ "reversals",
415
+ )
416
+ rm = _ask(
417
+ "Point id(s) to remove (space-separated, blank = none)", "", input_fn, p
418
+ )
419
+ remove_points = [int(x) for x in rm.split()]
420
+ ie = _ask(
421
+ "Point id(s) to widen error (repeat id to widen more, blank = none)",
422
+ "",
423
+ input_fn,
424
+ p,
425
+ )
426
+ increase_ids = [int(x) for x in ie.split()]
427
+ else:
428
+ p("Reversals: none.")
429
+
430
+ error_factors = _error_factors_from_list(increase_ids)
431
+
432
+ # Hiatus review.
433
+ treated = (
434
+ treat_reversals(dating_tbl, remove_points, error_factors=error_factors)
435
+ if (remove_points or error_factors)
436
+ else dating_tbl
437
+ )
438
+ candidates = detect_hiatus_candidates(treated)
439
+ if candidates.any:
440
+ depths = [round(float(x), 2) for x in candidates.depth]
441
+ p(f"Hiatus candidate depth(s): {depths}")
442
+ if preview_fn is not None:
443
+ from .plotting import plot_hiatus_candidates
444
+
445
+ preview_fn(
446
+ plot_hiatus_candidates(
447
+ treated, candidates, proxy=proxy_rec, samplename=samplename
448
+ ),
449
+ "hiatus",
450
+ )
451
+ hia_in = _ask(
452
+ "Hiatus depths (space-separated; 'auto' = candidates; blank = none)",
453
+ "",
454
+ input_fn,
455
+ p,
456
+ )
457
+ if hia_in.strip().lower() == "auto":
458
+ hia = [float(x) for x in candidates.depth]
459
+ elif hia_in.strip():
460
+ hia = [float(x) for x in hia_in.split()]
461
+ else:
462
+ hia = []
463
+
464
+ output_dir = _ask("Output folder", default_output_dir, input_fn, p)
465
+ do_plots = _ask_yesno("Save plots?", True, input_fn, p)
466
+
467
+ session = CopraSession(
468
+ dating_path=str(dating),
469
+ proxy_path=str(proxy),
470
+ layercount_path=str(layercount) if layercount else None,
471
+ M=int(M),
472
+ interp_method=interp,
473
+ hia_depth=hia,
474
+ remove_points=remove_points,
475
+ error_factors=error_factors,
476
+ confbnds_prxy=float(confp),
477
+ confbnds_age=float(confa),
478
+ samplename=samplename or Path(dating).stem,
479
+ proxyname=proxyname or "\\delta^{18}O",
480
+ seed=seed,
481
+ )
482
+ p("Running MC age model ...")
483
+ run_session(session)
484
+ p(f"Done: seed={session.seed}, redraws={session.ensemble.n_redraws}.")
485
+ paths = save_results(session, output_dir)
486
+ for kind, path in paths.items():
487
+ p(f" wrote {kind}: {path}")
488
+ if do_plots:
489
+ _save_plots(session, Path(output_dir), session.samplename)
490
+ return 0
491
+
492
+
493
+ def cmd_interactive(args: argparse.Namespace) -> int:
494
+ return run_interactive()
495
+
496
+
497
+ # --------------------------------------------------------------------------- #
498
+ # Parser
499
+ # --------------------------------------------------------------------------- #
500
+ def build_parser() -> argparse.ArgumentParser:
501
+ parser = argparse.ArgumentParser(
502
+ prog="copra-cli", description="COPRA age-depth modelling (command line)."
503
+ )
504
+ sub = parser.add_subparsers(dest="command")
505
+
506
+ run = sub.add_parser("run", help="run the full pipeline and write outputs")
507
+ run.add_argument(
508
+ "--dating",
509
+ help="dating-table file (depth age ageerror [upper ageerror for asymmetric])",
510
+ )
511
+ run.add_argument("--proxy", help="proxy-record file (depth value [value error])")
512
+ run.add_argument("--layercount", help="layer-count file (depth age deptherror)")
513
+ run.add_argument("--M", type=int, help="number of Monte Carlo realizations")
514
+ run.add_argument("--interp", choices=_INTERP_CHOICES, help="interpolation method")
515
+ run.add_argument("--hiatus", type=float, nargs="*", help="explicit hiatus depth(s)")
516
+ run.add_argument("--auto-hiatus", action="store_true", help="detect hiatuses")
517
+ run.add_argument(
518
+ "--remove-points",
519
+ type=int,
520
+ nargs="*",
521
+ help="1-based dating point id(s) to remove",
522
+ )
523
+ run.add_argument(
524
+ "--increase-error",
525
+ type=int,
526
+ nargs="*",
527
+ help="1-based id(s); repeat an id to widen its error by 10% again",
528
+ )
529
+ run.add_argument("--confbnds-proxy", type=float, dest="confbnds_proxy")
530
+ run.add_argument("--confbnds-age", type=float, dest="confbnds_age")
531
+ run.add_argument(
532
+ "--sample-size",
533
+ type=float,
534
+ dest="sample_size",
535
+ help="proxy sample size (depth units); adds a uniform distance error",
536
+ )
537
+ run.add_argument("--seed", type=int, help="random seed (recorded either way)")
538
+ run.add_argument("--samplename")
539
+ run.add_argument("--proxyname")
540
+ run.add_argument("--output-dir", dest="output_dir", help="output folder")
541
+ run.add_argument("--plots", action="store_true", help="also save PNG plots")
542
+ run.add_argument("--config", help="TOML config file with a [copra] table")
543
+ run.set_defaults(func=cmd_run)
544
+
545
+ check = sub.add_parser("check", help="report reversals / hiatus candidates (no MC)")
546
+ check.add_argument("--dating", required=True, help="dating-table file")
547
+ check.add_argument(
548
+ "--proxy",
549
+ help="proxy-record file; with --plots its depth coverage is overlaid",
550
+ )
551
+ check.add_argument(
552
+ "--plots", action="store_true", help="save reversal and hiatus review plots"
553
+ )
554
+ check.add_argument(
555
+ "--output-dir",
556
+ dest="output_dir",
557
+ help="folder for --plots output (default: current dir)",
558
+ )
559
+ check.set_defaults(func=cmd_check)
560
+
561
+ repro = sub.add_parser("reproduce", help="re-run a saved JSON session")
562
+ repro.add_argument("session", help="path to a d_*.json session file")
563
+ repro.add_argument(
564
+ "--output-dir",
565
+ dest="output_dir",
566
+ help="if given, write results/session/log here",
567
+ )
568
+ repro.set_defaults(func=cmd_reproduce)
569
+
570
+ interactive = sub.add_parser(
571
+ "interactive", help="guided, prompt-driven run (like the MATLAB workflow)"
572
+ )
573
+ interactive.set_defaults(func=cmd_interactive)
574
+ return parser
575
+
576
+
577
+ def main(argv: list[str] | None = None) -> int:
578
+ """Console-script entry point registered as ``copra-cli``.
579
+
580
+ With no subcommand it starts the interactive workflow (mirroring MATLAB
581
+ ``copra``); pass ``--help`` for the option reference.
582
+ """
583
+ parser = build_parser()
584
+ args = parser.parse_args(argv)
585
+ try:
586
+ if not getattr(args, "command", None):
587
+ return run_interactive()
588
+ return args.func(args)
589
+ except (CopraIOError, ValueError, FileNotFoundError) as exc:
590
+ print(f"error: {exc}", file=sys.stderr)
591
+ return 1
592
+
593
+
594
+ if __name__ == "__main__":
595
+ sys.exit(main())
copra/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Pure-computation core of COPRA (no Qt or matplotlib imports)."""