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 +8 -0
- copra/__main__.py +8 -0
- copra/cli.py +595 -0
- copra/core/__init__.py +1 -0
- copra/core/agemodel.py +333 -0
- copra/core/hiatus.py +129 -0
- copra/core/io.py +364 -0
- copra/core/logging_.py +354 -0
- copra/core/model.py +399 -0
- copra/core/montecarlo.py +308 -0
- copra/core/resample.py +164 -0
- copra/core/reversals.py +121 -0
- copra/gui/__init__.py +38 -0
- copra/gui/__main__.py +8 -0
- copra/gui/controllers/__init__.py +1 -0
- copra/gui/controllers/session_controller.py +134 -0
- copra/gui/main_window.py +1487 -0
- copra/gui/resources/copra_icon.png +0 -0
- copra/gui/resources/documentation.html +380 -0
- copra/gui/settings.py +68 -0
- copra/gui/widgets/__init__.py +1 -0
- copra/gui/widgets/age_table_view.py +150 -0
- copra/gui/widgets/file_picker.py +59 -0
- copra/gui/widgets/hiatus_dialog.py +1 -0
- copra/gui/widgets/layercount_dialog.py +83 -0
- copra/gui/widgets/plot_canvas.py +95 -0
- copra/gui/widgets/preferences_dialog.py +140 -0
- copra/plotting/__init__.py +33 -0
- copra/plotting/agemodel_plot.py +246 -0
- copra/plotting/preview_plot.py +185 -0
- copra/plotting/proxy_plot.py +114 -0
- copra2-2.0.0rc3.dist-info/METADATA +279 -0
- copra2-2.0.0rc3.dist-info/RECORD +35 -0
- copra2-2.0.0rc3.dist-info/WHEEL +4 -0
- copra2-2.0.0rc3.dist-info/entry_points.txt +3 -0
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
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)."""
|