dssrr 0.1.0__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.
Files changed (59) hide show
  1. dssrr/__init__.py +231 -0
  2. dssrr/cli.py +305 -0
  3. dssrr/config.py +175 -0
  4. dssrr/core.py +553 -0
  5. dssrr/discovery.py +204 -0
  6. dssrr/gui/__init__.py +33 -0
  7. dssrr/gui/__main__.py +21 -0
  8. dssrr/gui/batch_repair.py +782 -0
  9. dssrr/gui/depulse_dialog.py +2718 -0
  10. dssrr/gui/export_report_dialog.py +658 -0
  11. dssrr/gui/i18n.py +413 -0
  12. dssrr/gui/icons/__init__.py +39 -0
  13. dssrr/gui/icons/dssrr-128.png +0 -0
  14. dssrr/gui/icons/dssrr-16.png +0 -0
  15. dssrr/gui/icons/dssrr-24.png +0 -0
  16. dssrr/gui/icons/dssrr-256.png +0 -0
  17. dssrr/gui/icons/dssrr-32.png +0 -0
  18. dssrr/gui/icons/dssrr-48.png +0 -0
  19. dssrr/gui/icons/dssrr-64.png +0 -0
  20. dssrr/gui/icons/dssrr.ico +0 -0
  21. dssrr/gui/icons/dssrr.png +0 -0
  22. dssrr/gui/icons/dssrr.svg +24 -0
  23. dssrr/gui/icons/make_icon.py +209 -0
  24. dssrr/gui/main.py +846 -0
  25. dssrr/gui/manual_repair.py +1077 -0
  26. dssrr/gui/nav_hint.py +182 -0
  27. dssrr/gui/plot_text_editor.py +505 -0
  28. dssrr/gui/translations_zh.py +665 -0
  29. dssrr/gui/viewer.py +3008 -0
  30. dssrr/i18n.py +122 -0
  31. dssrr/paths.py +121 -0
  32. dssrr/repair_lib/__init__.py +38 -0
  33. dssrr/repair_lib/anomaly_repair/__init__.py +55 -0
  34. dssrr/repair_lib/anomaly_repair/config.py +26 -0
  35. dssrr/repair_lib/anomaly_repair/layered_reference_spectrum.py +506 -0
  36. dssrr/repair_lib/anomaly_repair/moonquake_protected_spike.py +371 -0
  37. dssrr/repair_lib/anomaly_repair/reference_spectrum.py +42 -0
  38. dssrr/repair_lib/anomaly_repair/spectral.py +22 -0
  39. dssrr/repair_lib/anomaly_repair/synthesizer.py +29 -0
  40. dssrr/repair_lib/anomaly_repair/verifier.py +21 -0
  41. dssrr/repair_lib/apollo_catalog/gagnepian_2006_catalog.csv +59 -0
  42. dssrr/repair_lib/apollo_catalog/lognonne_2003_catalog.csv +60 -0
  43. dssrr/repair_lib/depulse_advanced.py +973 -0
  44. dssrr/repair_lib/depulse_nn.py +187 -0
  45. dssrr/repair_lib/e7_detector.py +622 -0
  46. dssrr/repair_lib/e7_repair_build.py +1719 -0
  47. dssrr/repair_lib/moonquake_catalog.json +9515 -0
  48. dssrr/repair_lib/repair_engine.py +673 -0
  49. dssrr/repair_lib/repair_settings.py +139 -0
  50. dssrr/repair_lib/report_plot.py +913 -0
  51. dssrr/spectral.py +209 -0
  52. dssrr/synthesizer.py +716 -0
  53. dssrr/verifier.py +309 -0
  54. dssrr-0.1.0.dist-info/METADATA +483 -0
  55. dssrr-0.1.0.dist-info/RECORD +59 -0
  56. dssrr-0.1.0.dist-info/WHEEL +5 -0
  57. dssrr-0.1.0.dist-info/entry_points.txt +3 -0
  58. dssrr-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. dssrr-0.1.0.dist-info/top_level.txt +1 -0
dssrr/__init__.py ADDED
@@ -0,0 +1,231 @@
1
+ """DSSRR — Dual-Sided Spectral Reference Reconstruction.
2
+
3
+ Training-free, statistically faithful long-gap repair for planetary and
4
+ seismic time series. DSSRR fills a corrupted or missing interval by fusing
5
+ power-spectral-density estimates taken from the healthy data on **both** sides
6
+ of the gap, synthesising a replacement with realistic waveform texture, and
7
+ blending it into the surrounding trace. No training data and no model fitting
8
+ are required, and the method stays stable for gaps from seconds to over
9
+ 90 minutes.
10
+
11
+ Quick start
12
+ -----------
13
+ >>> import numpy as np
14
+ >>> from dssrr import DSSRR
15
+ >>> sr = 6.625 # Apollo long-period sampling rate
16
+ >>> model = DSSRR(sr=sr, reference_sec=300, seed=42)
17
+ >>> repaired = model.repair(data, start=anomaly_start, end=anomaly_end)
18
+
19
+ Package layout
20
+ --------------
21
+ The package is layered so that the scientific core stays lightweight:
22
+
23
+ ``dssrr.core`` / ``spectral`` / ``synthesizer`` / ``verifier`` / ``config``
24
+ The algorithm itself. Depends on **numpy + scipy only** — importing
25
+ :mod:`dssrr` never pulls in a GUI toolkit.
26
+ ``dssrr.cli``
27
+ Cross-platform command-line batch repair (``dssrr-batch``).
28
+ ``dssrr.gui``
29
+ PyQt5 desktop application (``dssrr-gui``): trace viewer, manual repair
30
+ panel and batch runner.
31
+ ``dssrr.repair_lib``
32
+ Apollo-specific detection/repair pipeline (E7 detector, multi-level repair
33
+ routing, report plotting) used by the GUI and the batch command.
34
+ ``dssrr.paths``
35
+ Canonical locations of the optional user settings / catalogue files.
36
+ ``dssrr.i18n``
37
+ Single source of truth for the language of user-facing report text
38
+ (default **English**; override with the ``DSSRR_LANG`` environment
39
+ variable). The system locale is deliberately not consulted.
40
+ ``dssrr.gui.i18n`` + ``dssrr.gui.translations_zh``
41
+ Bilingual UI support. A ``QTranslator`` subclass backed by a plain
42
+ ``{english: chinese}`` dict makes every existing ``self.tr()`` call site
43
+ translatable without a ``.qm`` toolchain. Still Qt-only — the core
44
+ library never imports it.
45
+
46
+ Citation
47
+ --------
48
+ Yu, S., Li, X. (2026). Dual-Sided Spectral Reference Reconstruction for
49
+ long-duration artifact repair in Apollo lunar seismic records.
50
+ *Seismological Research Letters* (submitted).
51
+
52
+ License: MIT (see ``LICENSE``).
53
+ """
54
+ from __future__ import annotations
55
+ import numpy as np
56
+ from .core import ReferenceSpectrumReplacer
57
+ from .config import get_config
58
+
59
+ __version__ = "0.1.0"
60
+
61
+ def dssrr_gui():
62
+ """启动 DSSRR 桌面 GUI(查看器 / 手动重处理 / 批量去异常)。
63
+
64
+ 惰性导入 dssrr.gui,避免核心库 import 时强制依赖 PyQt5。
65
+ """
66
+ from .gui import launch_gui
67
+ launch_gui()
68
+
69
+
70
+ def dssrr_batch(argv=None):
71
+ """命令行批量去异常(Linux / macOS / Windows PowerShell 通用)。
72
+
73
+ 惰性导入 dssrr.cli,命令行模式无需安装 PyQt5。
74
+ """
75
+ from .cli import main
76
+ return main(argv)
77
+
78
+
79
+ __all__ = ["DSSRR", "ReferenceSpectrumReplacer", "auto_reference_length",
80
+ "dssrr_gui", "dssrr_batch", "__version__"]
81
+
82
+
83
+ class DSSRR:
84
+ """High-level convenience wrapper around :class:`ReferenceSpectrumReplacer`.
85
+
86
+ Use this when you just want to repair a trace. For full control over the
87
+ configuration dictionary (spectral estimation, synthesis, boundary joining
88
+ and verification thresholds), use :class:`ReferenceSpectrumReplacer`
89
+ directly.
90
+
91
+ Parameters
92
+ ----------
93
+ sr : float
94
+ Sampling rate in Hz. Must be positive. Apollo long-period data is
95
+ 6.625 Hz.
96
+ reference_sec : float, optional
97
+ Default reference window length **per side**, in seconds (default
98
+ 300.0). For long gaps prefer :func:`auto_reference_length`.
99
+ seed : int, optional
100
+ Random seed used for deterministic synthesis (default 42). Pass
101
+ ``None`` at call time for fresh randomness — note that the result is
102
+ then not reproducible.
103
+ quantize : bool, optional
104
+ If ``True``, round the repaired samples to integers using
105
+ round-half-to-even. Enable this only for traces stored as raw integer
106
+ counts (e.g. the Apollo archive); leave it ``False`` for physical-unit
107
+ data such as velocity or displacement. Can be overridden per call.
108
+ config : dict, optional
109
+ Configuration overrides merged onto :data:`dssrr.config.DEFAULT_CONFIG`.
110
+
111
+ Examples
112
+ --------
113
+ >>> import numpy as np
114
+ >>> from dssrr import DSSRR, auto_reference_length
115
+ >>> sr = 6.625
116
+ >>> data = 500.0 + np.random.default_rng(0).normal(0, 3, 20000)
117
+ >>> data[6000:9001] = 0.0 # a 7.5-minute dropout
118
+ >>> ref = auto_reference_length(3001, sr) # 226.5 s per side
119
+ >>> model = DSSRR(sr=sr, reference_sec=ref, seed=42)
120
+ >>> repaired = model.repair(data, 6000, 9000)
121
+ >>> repaired.shape == data.shape
122
+ True
123
+
124
+ Notes
125
+ -----
126
+ DSSRR reproduces the **second-order statistics** of the surrounding trace
127
+ (power spectrum, autocorrelation, energy). The original phase inside the
128
+ gap is not recoverable, so a one-off transient that happened during the
129
+ dropout cannot be reconstructed.
130
+ """
131
+
132
+ def __init__(self, sr: float, reference_sec: float = 300.0,
133
+ seed: int = 42, quantize: bool = False,
134
+ config: dict | None = None):
135
+ if sr <= 0:
136
+ raise ValueError("Sampling rate must be positive")
137
+ self.sr = float(sr)
138
+ self.reference_sec = float(reference_sec)
139
+ self.seed = seed
140
+ self.quantize = bool(quantize)
141
+ self._replacer = ReferenceSpectrumReplacer(sr=sr, config=config)
142
+
143
+ def repair(self, data: np.ndarray, start: int, end: int,
144
+ *, reference_before_sec: float | None = None,
145
+ reference_after_sec: float | None = None,
146
+ safety_gap_sec: float | None = None,
147
+ seed: int | None = None,
148
+ quantize: bool | None = None) -> np.ndarray:
149
+ """Repair ``data[start:end + 1]`` in place-free fashion.
150
+
151
+ Parameters
152
+ ----------
153
+ data : numpy.ndarray
154
+ 1-D waveform. Never modified in place — a repaired copy is
155
+ returned, and every sample outside ``[start, end]`` is copied
156
+ through bit-identically.
157
+ start, end : int
158
+ Inclusive sample indices of the anomaly core.
159
+ reference_before_sec, reference_after_sec : float, optional
160
+ Reference window length per side. Default to ``reference_sec``.
161
+ safety_gap_sec : float, optional
162
+ Guard interval between the anomaly core and each reference window,
163
+ in seconds. ``None`` (the default) defers to the replacer's own
164
+ default so there is a single source of truth for this value.
165
+ seed : int, optional
166
+ Override the instance seed. ``None`` means "use the instance
167
+ seed"; to request fresh randomness pass ``seed=None`` to the
168
+ constructor instead.
169
+ quantize : bool, optional
170
+ Override the instance ``quantize`` flag for this call only.
171
+
172
+ Returns
173
+ -------
174
+ numpy.ndarray
175
+ A repaired copy of ``data`` with the same shape and dtype layout
176
+ (float; integer input becomes integer again when ``quantize``).
177
+ """
178
+ rb = self.reference_sec if reference_before_sec is None else reference_before_sec
179
+ ra = self.reference_sec if reference_after_sec is None else reference_after_sec
180
+ s = self.seed if seed is None else seed
181
+ q = self.quantize if quantize is None else bool(quantize)
182
+ kwargs = dict(
183
+ reference_before_sec=rb,
184
+ reference_after_sec=ra,
185
+ random_seed=s,
186
+ quantize=q,
187
+ )
188
+ if safety_gap_sec is not None:
189
+ kwargs["reference_gap_sec"] = float(safety_gap_sec)
190
+ repaired, _ = self._replacer.replace(
191
+ np.asarray(data, dtype=float), int(start), int(end), **kwargs)
192
+ return repaired
193
+
194
+ def repair_with_report(self, data: np.ndarray, start: int, end: int,
195
+ **kwargs) -> tuple[np.ndarray, dict]:
196
+ """Repair and also return the full provenance / quality report.
197
+
198
+ Every :meth:`ReferenceSpectrumReplacer.replace` keyword is forwarded,
199
+ so this is the way to reach ``reference_gap_sec``, ``quantize``,
200
+ ``random_seed`` and friends while still using the convenience wrapper.
201
+
202
+ Returns
203
+ -------
204
+ (numpy.ndarray, dict)
205
+ The repaired trace and a report describing the reference windows
206
+ used, baseline alignment, verification metrics and known
207
+ limitations.
208
+ """
209
+ return self._replacer.replace(np.asarray(data, dtype=float),
210
+ int(start), int(end), **kwargs)
211
+
212
+
213
+ def auto_reference_length(anomaly_samples: int, sr: float,
214
+ min_sec: float = 120.0, max_sec: float = 600.0) -> float:
215
+ """Pick a reference-window length from the anomaly duration.
216
+
217
+ Returns ``clip(anomaly_duration / 2, min_sec, max_sec)`` seconds — the
218
+ protocol used for the DSSRR paper (120–600 s for Apollo data). Half the
219
+ anomaly length is enough for a stable spectral estimate, while the bounds
220
+ keep the window short enough to stay locally stationary.
221
+
222
+ Examples
223
+ --------
224
+ >>> auto_reference_length(int(60 * 6.625), 6.625) # 60 s anomaly
225
+ 120.0
226
+ >>> auto_reference_length(int(2000 * 6.625), 6.625) # very long anomaly
227
+ 600.0
228
+ """
229
+ anom_sec = float(anomaly_samples) / float(sr)
230
+ return float(np.clip(anom_sec / 2.0, min_sec, max_sec))
231
+
dssrr/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DSSRR command-line batch repair (``dssrr-batch``).
3
+
4
+ Cross-platform (Linux / macOS / Windows PowerShell) entry point for repairing a
5
+ whole archive month by month. It runs **the same pipeline as the desktop GUI's
6
+ Batch Repair page**, but without importing PyQt, so it works on headless
7
+ servers.
8
+
9
+ Pipeline
10
+ --------
11
+ ::
12
+
13
+ scan scope -> E7 anomaly detection -> per-file multi-level repair
14
+ -> moonquake-catalogue protection -> write catalog CSV
15
+ -> automatic verification -> summary JSON
16
+
17
+ Directory convention::
18
+
19
+ <raw_root>/1976/01/XA.S12.19760113.mseed # input, read-only
20
+ <fix_root>/1976/01/XA.S12.19760113.mseed # output, mirror structure
21
+ <catalog_dir>/E7_catalog_197601.csv # per-month repair detail
22
+ <catalog_dir>/verify_197601.json # per-month verification
23
+ <catalog_dir>/batch_summary.json # overall summary
24
+
25
+ Usage
26
+ -----
27
+ ::
28
+
29
+ dssrr-batch --raw-root /data/apollo/raw --fix-root /data/apollo/repair \\
30
+ --stations S12,S15 --d0 19760101 --d1 19760229
31
+
32
+ Exit status is ``0`` when verification passes for every repaired file, and
33
+ ``1`` when any file failed or the arguments were invalid. See ``docs/CLI.md``
34
+ for the full option list and worked examples.
35
+
36
+ Programmatic use: :func:`quick_scan` and :func:`batch_repair` are importable
37
+ directly and take plain Python arguments.
38
+
39
+ Dependencies: numpy, obspy, pandas (no PyQt5).
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import sys
47
+ import time
48
+
49
+ from . import discovery
50
+
51
+ DEFAULT_SR = 6.625
52
+ SEC_PER_FILE = 0.9 # 1976-01 measured baseline (same estimate as the GUI)
53
+
54
+
55
+ # ----------------------------------------------------------------------
56
+ # 文件扫描 —— 实现见 dssrr.discovery(与 GUI batch_repair 共用同一份规则)
57
+ # ----------------------------------------------------------------------
58
+ _iter_scope_files = discovery.iter_scope_files
59
+
60
+
61
+ def quick_scan(raw_root, stations, d0, d1):
62
+ """快速统计:文件数、按年月、总字节、预计耗时(不读文件内容)。"""
63
+ n, nbytes = 0, 0
64
+ by_ym = {}
65
+ for f, y, m in _iter_scope_files(raw_root, stations, d0, d1):
66
+ n += 1
67
+ by_ym[f"{y:04d}-{m:02d}"] = by_ym.get(f"{y:04d}-{m:02d}", 0) + 1
68
+ try:
69
+ nbytes += os.path.getsize(f)
70
+ except OSError:
71
+ pass
72
+ return {
73
+ "files": n, "bytes": nbytes, "by_ym": by_ym,
74
+ "est_sec": n * SEC_PER_FILE,
75
+ }
76
+
77
+
78
+ # ----------------------------------------------------------------------
79
+ # 批处理主流程(等价 GUI BatchWorker.run,无 Qt 信号)
80
+ # ----------------------------------------------------------------------
81
+ def batch_repair(raw_root, fix_root, stations, d0, d1,
82
+ catalog_dir=None, use_catalog=True,
83
+ det_kwargs=None, params=None, sr=DEFAULT_SR,
84
+ log=None, stop_flag=None):
85
+ """执行批量去异常并返回汇总 dict。stop_flag 为可选的 callable() -> bool。"""
86
+ if log is None:
87
+ log = lambda msg: print(msg) # noqa: E731
88
+ from dssrr.repair_lib.e7_repair_build import (repair_one_file,
89
+ write_catalog_csv,
90
+ verify_file)
91
+ from dssrr.repair_lib.e7_detector import E7Detector
92
+
93
+ det = E7Detector(sr=sr, **(det_kwargs or {}))
94
+ if catalog_dir is None:
95
+ catalog_dir = os.path.join(fix_root, "catalogs")
96
+
97
+ files = list(_iter_scope_files(raw_root, stations, d0, d1))
98
+ total = len(files)
99
+ log(f"[scope] {total} file(s), stations={','.join(stations) or 'ALL'}, "
100
+ f"{d0}~{d1}")
101
+ if total == 0:
102
+ return {"files": 0, "repaired": 0, "gaps": 0, "errors": [],
103
+ "months": [], "verify": None, "elapsed": 0.0}
104
+
105
+ t0 = time.time()
106
+ summary = {"files": total, "repaired": 0, "gaps": 0, "errors": [],
107
+ "months": [], "verify": None, "elapsed": 0.0}
108
+ groups = {}
109
+ for f, y, m in files:
110
+ groups.setdefault(f"{y:04d}{m:02d}", []).append(f)
111
+
112
+ for i, ym in enumerate(sorted(groups), 1):
113
+ if stop_flag and stop_flag():
114
+ log("[stop] user requested stop")
115
+ break
116
+ out_ym = os.path.join(fix_root, ym[:4], ym[4:6])
117
+ rows = []
118
+ log(f"[{i}/{len(groups)}] month {ym}: {len(groups[ym])} file(s)")
119
+ for f in sorted(groups[ym]):
120
+ if stop_flag and stop_flag():
121
+ break
122
+ try:
123
+ nrep, ngap = repair_one_file(
124
+ f, det, out_ym, verbose=False, catalog_rows=rows,
125
+ params=params, use_catalog=use_catalog)
126
+ summary["repaired"] += nrep
127
+ summary["gaps"] += ngap
128
+ log(f" repaired {os.path.basename(f)} "
129
+ f"(rep={nrep} gap={ngap})")
130
+ except Exception as ex:
131
+ summary["errors"].append(
132
+ (os.path.basename(f), str(ex)[:120]))
133
+ log(f" ERR {os.path.basename(f)}: {ex}")
134
+ if rows and catalog_dir:
135
+ try:
136
+ os.makedirs(catalog_dir, exist_ok=True)
137
+ cpath = os.path.join(catalog_dir, f"E7_catalog_{ym}.csv")
138
+ write_catalog_csv(cpath, rows)
139
+ log(f"[catalog] {ym}: {len(rows)} row(s) -> {cpath}")
140
+ except Exception as ex:
141
+ log(f"[catalog] {ym} ERR: {ex}")
142
+ summary["months"].append(ym)
143
+
144
+ # 自动验收:只验收本次处理过的文件
145
+ log("[verify] verifying repaired files...")
146
+ try:
147
+ ver = {"ok": True, "n_files": 0, "n_fail": 0, "n_skip": 0,
148
+ "by_month": {}}
149
+ for ym in sorted(groups):
150
+ r_raw = os.path.join(raw_root, ym[:4], ym[4:6])
151
+ r_fix = os.path.join(fix_root, ym[:4], ym[4:6])
152
+ ym_files, ym_fail = 0, 0
153
+ for f in sorted(groups[ym]):
154
+ fb = os.path.basename(f)
155
+ fix_path = os.path.join(r_fix, fb)
156
+ if not os.path.exists(fix_path):
157
+ ver["n_skip"] += 1
158
+ log(f" SKIP {fb}: no repaired file")
159
+ continue
160
+ try:
161
+ rep = verify_file(f, fix_path, det,
162
+ use_catalog=use_catalog)
163
+ ver["n_files"] += 1
164
+ ym_files += 1
165
+ if not rep.get("ok"):
166
+ ver["n_fail"] += 1
167
+ ym_fail += 1
168
+ ver["ok"] = False
169
+ log(f" FAIL {fb}: "
170
+ f"{'; '.join(rep.get('issues', []))}")
171
+ except Exception as ex:
172
+ ver["n_fail"] += 1
173
+ ym_fail += 1
174
+ ver["ok"] = False
175
+ log(f" ERR {fb}: {ex}")
176
+ ver["by_month"][ym] = {"ok": ym_fail == 0, "n_files": ym_files,
177
+ "n_fail": ym_fail}
178
+ if catalog_dir:
179
+ try:
180
+ os.makedirs(catalog_dir, exist_ok=True)
181
+ vpath = os.path.join(
182
+ catalog_dir,
183
+ "verify_" + "_".join(sorted(summary["months"]))[:12]
184
+ + ".json")
185
+ with open(vpath, "w", encoding="utf-8") as f:
186
+ json.dump(ver, f, ensure_ascii=False, indent=1)
187
+ log(f"[verify] json -> {vpath}")
188
+ except Exception as ex:
189
+ log(f"[verify] json ERR: {ex}")
190
+ summary["verify"] = ver
191
+ except Exception as ex:
192
+ summary["verify"] = {"ok": False, "error": str(ex)[:200]}
193
+
194
+ summary["elapsed"] = time.time() - t0
195
+ log(f"[done] repaired={summary['repaired']} gaps={summary['gaps']} "
196
+ f"errors={len(summary['errors'])} elapsed={summary['elapsed']:.1f}s "
197
+ f"verify_ok={summary['verify'].get('ok') if summary['verify'] else None}")
198
+ return summary
199
+
200
+
201
+ # ----------------------------------------------------------------------
202
+ # 参数解析 / 入口
203
+ # ----------------------------------------------------------------------
204
+ def _positive_date(v):
205
+ v = v.strip()
206
+ if not (len(v) == 8 and v.isdigit()):
207
+ raise argparse.ArgumentTypeError(f"bad YYYYMMDD: {v!r}")
208
+ return int(v)
209
+
210
+
211
+ def _run(args):
212
+ if not args.raw_root or not os.path.isdir(args.raw_root):
213
+ sys.exit(f"[error] raw root not found: {args.raw_root!r}")
214
+ stations = [s.strip() for s in (args.stations or "").split(",")
215
+ if s.strip()]
216
+ d0, d1 = sorted((args.d0, args.d1))
217
+ r = quick_scan(args.raw_root, stations, d0, d1)
218
+ print(f"[scan] {r['files']} file(s), {r['bytes']/1024**3:.2f} GB, "
219
+ f"{len(r['by_ym'])} month(s), est. {r['est_sec']/60:.1f} min")
220
+
221
+ det_kwargs, params = {}, None
222
+ if args.settings:
223
+ try:
224
+ from dssrr.paths import settings_read_path
225
+ from dssrr.repair_lib import repair_settings as RS
226
+ s = RS.load_settings()
227
+ det_kwargs = dict(s.get("detect", {}) or {})
228
+ params = dict(s.get("repair", {}) or {})
229
+ print(f"[settings] loaded user settings "
230
+ f"({settings_read_path()})")
231
+ except Exception as ex:
232
+ print(f"[warning] settings load failed, using defaults: {ex}")
233
+
234
+ summary = batch_repair(
235
+ args.raw_root, args.fix_root, stations, d0, d1,
236
+ catalog_dir=args.catalog_dir, use_catalog=not args.no_catalog,
237
+ det_kwargs=det_kwargs, params=params, sr=args.sr)
238
+
239
+ # 汇总落盘
240
+ os.makedirs(args.catalog_dir, exist_ok=True) if args.catalog_dir else None
241
+ out_json = args.summary_json
242
+ with open(out_json, "w", encoding="utf-8") as f:
243
+ json.dump(summary, f, ensure_ascii=False, indent=1,
244
+ default=lambda o: o if not hasattr(o, "isoformat") else
245
+ str(o))
246
+ print(f"[summary] -> {out_json}")
247
+ ok = summary["verify"] and summary["verify"].get("ok")
248
+ if ok:
249
+ print("[RESULT] VERIFY OK")
250
+ else:
251
+ print("[RESULT] VERIFY FAIL "
252
+ f"(fail={summary['verify'].get('n_fail', '?')} "
253
+ f"skip={summary['verify'].get('n_skip', '?')})" if ok is False
254
+ else "[RESULT] VERIFY FAIL (see json)")
255
+ return 0 if ok else 1
256
+
257
+
258
+ def build_parser():
259
+ p = argparse.ArgumentParser(
260
+ prog="dssrr batch-repair",
261
+ description="DSSRR 批量去异常命令行工具(Linux/PowerShell 通用)")
262
+ p.add_argument("--raw-root", required=True,
263
+ help="raw 库根目录;目录结构不限,文件名需为 "
264
+ "NET.STA.YYYYMMDD.mseed 或 "
265
+ "NET.STA.LOC.CHA.START-END.mseed")
266
+ p.add_argument("--fix-root", required=True,
267
+ help="修复库输出根目录(自动创建)")
268
+ p.add_argument("--stations", default="",
269
+ help="逗号分隔台站列表,如 S12,S15;缺省=全部")
270
+ p.add_argument("--d0", type=_positive_date, default=19760101,
271
+ help="开始日期 YYYYMMDD")
272
+ p.add_argument("--d1", type=_positive_date, default=19760201,
273
+ help="结束日期 YYYYMMDD")
274
+ p.add_argument("--catalog-dir", default=None,
275
+ help="catalog/verify 输出目录;缺省 = <fix-root>/catalogs")
276
+ p.add_argument("--no-catalog", action="store_true",
277
+ help="关闭月震目录保护(检测器判定事件全部转 repair)")
278
+ p.add_argument("--settings", action="store_true",
279
+ help="读取用户设置 ~/.dssrr_settings.json "
280
+ "(检测/修复参数);缺省=内置默认")
281
+ p.add_argument("--sr", type=float, default=DEFAULT_SR,
282
+ help=f"采样率 Hz(缺省 {DEFAULT_SR})")
283
+ p.add_argument("--summary-json", default=None,
284
+ help="汇总 JSON 输出路径;缺省 = <catalog-dir>/"
285
+ "batch_summary.json")
286
+ return p
287
+
288
+
289
+ def main(argv=None):
290
+ args = build_parser().parse_args(argv)
291
+ if args.summary_json is None:
292
+ cat = args.catalog_dir or os.path.join(args.fix_root, "catalogs")
293
+ args.summary_json = os.path.join(cat, "batch_summary.json")
294
+ if args.catalog_dir is None:
295
+ args.catalog_dir = os.path.join(args.fix_root, "catalogs")
296
+ try:
297
+ rc = _run(args)
298
+ except KeyboardInterrupt:
299
+ print("\n[interrupted by user]", file=sys.stderr)
300
+ rc = 130
301
+ sys.exit(rc)
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()