das-mccc 0.2.0__tar.gz

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.
das_mccc-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Jaewoo Kim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: das-mccc
3
+ Version: 0.2.0
4
+ Summary: Iterative network MCCC refinement of DAS arrival curves with absolute anchoring and Ricker polarity QC
5
+ Author-email: Jaewoo Kim <jk103@rice.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/Jaewoo-Kim-Rice/das-mccc
8
+ Project-URL: Algorithm, https://github.com/Jaewoo-Kim-Rice/das-mccc/blob/main/docs/algorithm.md
9
+ Keywords: DAS,distributed acoustic sensing,microseismic,cross-correlation,phase picking
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: Physics
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy
21
+ Requires-Dist: scipy
22
+ Provides-Extra: numba
23
+ Requires-Dist: numba; extra == "numba"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # das-mccc
30
+
31
+ Refine a DAS arrival curve with an iterative network multi-channel cross-correlation
32
+ (MCCC), anchor its absolute level on the aligned stack, and return per-channel polarity
33
+ and quality measures. Arrays in, arrays out: no site, file or path concepts.
34
+
35
+ The core is the refiner of [das-focmec](https://github.com/Jaewoo-Kim-Rice/das-focmec)
36
+ (`das_focmec.processing.mccc_core.ultra_mccc_iterative`), extracted with its history so it
37
+ can be used by any picker. Started from a rough curve (a VLM trace, a bracket, a
38
+ theoretical moveout) it recovers the shape of the arrival to the precision of a human
39
+ curve: on 24 CAPE 2025 reads the shape MAD against human picks went from 2.2 to 1.95 ms
40
+ (P) and 4.5 to 4.15 ms (S), and a curve started on the wrong lobe went from 5.6 to 2.65 ms.
41
+
42
+ ## Install
43
+
44
+ ```
45
+ pip install -e . # numpy, scipy
46
+ pip install -e '.[numba]' # fast pairwise correlation (strongly recommended)
47
+ pip install -e '.[dev]' # pytest, ruff
48
+ ```
49
+
50
+ Without numba the pairwise correlation runs in pure numpy: identical results, one to two
51
+ orders of magnitude slower, and a warning is logged at import.
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ import numpy as np
57
+ from dasmccc import refine_curve, refine_phases, DIRECT, SECONDARY
58
+
59
+ # waveform: (n_channels, n_samples) float, filtered as you like
60
+ # curve : (n_channels,) arrival in samples, NaN where the phase is not picked
61
+ res = refine_curve(waveform, curve, DIRECT)
62
+
63
+ res.curve # refined arrival (samples), NaN where the input was NaN
64
+ res.curve_relative # same shape, at the initial curve's level (no anchor)
65
+ res.anchor_offset # samples added by the anchor rule (NaN if the rule refused)
66
+ res.polarity # -1 / 0 / +1 per channel
67
+ res.snr, res.coherence, res.kept
68
+ res.aligned, res.stack # the aligned window and its stack, for plots
69
+
70
+ # several phases of one gather, strongest first; refined phases are masked for the next and a
71
+ # narrow tapered pre-mask (+-40 samples, 10 taper) keeps them out of the correlation
72
+ out = refine_phases(waveform, {"S": s_curve, "P": p_curve, "SP": sp_curve})
73
+ # several curves of one tag: any keys, plus a key -> tag map
74
+ out = refine_phases(waveform, {"S": s_curve, "R1": r1, "R2": r2},
75
+ tags={"S": "S", "R1": "REFL", "R2": "REFL"})
76
+ ```
77
+
78
+ Settings live in `RefineConfig` (everything in samples and channels). `DIRECT` is the
79
+ das-focmec configuration for direct waves (window 200, corr_len 200, smoothness 50, four
80
+ passes, pre-mask 100); `SECONDARY` narrows it for conversions and reflections (window
81
+ 120, corr_len 100, three passes, pre-mask 50). Both were calibrated at 1 kHz and 2 m channel
82
+ spacing; `docs/algorithm.md` gives the conversion to other rates and spacings, what each
83
+ knob does, and the anchoring and masking rules.
84
+
85
+ ## For das-focmec
86
+
87
+ `dasmccc.legacy` exposes `ultra_mccc_iterative` and `diff_corr_ric` with the das-focmec
88
+ signatures and identical results, so `das_focmec.processing.workflows` only changes its
89
+ import line.
90
+
91
+ ## What it does not do
92
+
93
+ * It does not re-pick. The initial curve decides which arrival and roughly which lobe is
94
+ refined; MCCC measures relative delays within `pair_slope` samples per channel of it.
95
+ * The anchor moves the whole curve by one offset measured on the stack (secondary phases
96
+ inherit their parent's). The first-lobe rule sits about 5 ms before the human pick for P
97
+ and within a few ms of it for S on the CAPE 2025 fibres, with a per-fibre constant;
98
+ calibrate it per site against a few human picks when onsets are needed.
99
+ * Sub-sample precision: the alignment is integer; tau is a float but the returned curve
100
+ inherits the integer initial alignment plus the smoothed tau.
101
+
102
+ ## Development
103
+
104
+ ```
105
+ PYTHONPATH=src pytest -q
106
+ ruff check src tests && ruff format --check src tests
107
+ ```
@@ -0,0 +1,79 @@
1
+ # das-mccc
2
+
3
+ Refine a DAS arrival curve with an iterative network multi-channel cross-correlation
4
+ (MCCC), anchor its absolute level on the aligned stack, and return per-channel polarity
5
+ and quality measures. Arrays in, arrays out: no site, file or path concepts.
6
+
7
+ The core is the refiner of [das-focmec](https://github.com/Jaewoo-Kim-Rice/das-focmec)
8
+ (`das_focmec.processing.mccc_core.ultra_mccc_iterative`), extracted with its history so it
9
+ can be used by any picker. Started from a rough curve (a VLM trace, a bracket, a
10
+ theoretical moveout) it recovers the shape of the arrival to the precision of a human
11
+ curve: on 24 CAPE 2025 reads the shape MAD against human picks went from 2.2 to 1.95 ms
12
+ (P) and 4.5 to 4.15 ms (S), and a curve started on the wrong lobe went from 5.6 to 2.65 ms.
13
+
14
+ ## Install
15
+
16
+ ```
17
+ pip install -e . # numpy, scipy
18
+ pip install -e '.[numba]' # fast pairwise correlation (strongly recommended)
19
+ pip install -e '.[dev]' # pytest, ruff
20
+ ```
21
+
22
+ Without numba the pairwise correlation runs in pure numpy: identical results, one to two
23
+ orders of magnitude slower, and a warning is logged at import.
24
+
25
+ ## Use
26
+
27
+ ```python
28
+ import numpy as np
29
+ from dasmccc import refine_curve, refine_phases, DIRECT, SECONDARY
30
+
31
+ # waveform: (n_channels, n_samples) float, filtered as you like
32
+ # curve : (n_channels,) arrival in samples, NaN where the phase is not picked
33
+ res = refine_curve(waveform, curve, DIRECT)
34
+
35
+ res.curve # refined arrival (samples), NaN where the input was NaN
36
+ res.curve_relative # same shape, at the initial curve's level (no anchor)
37
+ res.anchor_offset # samples added by the anchor rule (NaN if the rule refused)
38
+ res.polarity # -1 / 0 / +1 per channel
39
+ res.snr, res.coherence, res.kept
40
+ res.aligned, res.stack # the aligned window and its stack, for plots
41
+
42
+ # several phases of one gather, strongest first; refined phases are masked for the next and a
43
+ # narrow tapered pre-mask (+-40 samples, 10 taper) keeps them out of the correlation
44
+ out = refine_phases(waveform, {"S": s_curve, "P": p_curve, "SP": sp_curve})
45
+ # several curves of one tag: any keys, plus a key -> tag map
46
+ out = refine_phases(waveform, {"S": s_curve, "R1": r1, "R2": r2},
47
+ tags={"S": "S", "R1": "REFL", "R2": "REFL"})
48
+ ```
49
+
50
+ Settings live in `RefineConfig` (everything in samples and channels). `DIRECT` is the
51
+ das-focmec configuration for direct waves (window 200, corr_len 200, smoothness 50, four
52
+ passes, pre-mask 100); `SECONDARY` narrows it for conversions and reflections (window
53
+ 120, corr_len 100, three passes, pre-mask 50). Both were calibrated at 1 kHz and 2 m channel
54
+ spacing; `docs/algorithm.md` gives the conversion to other rates and spacings, what each
55
+ knob does, and the anchoring and masking rules.
56
+
57
+ ## For das-focmec
58
+
59
+ `dasmccc.legacy` exposes `ultra_mccc_iterative` and `diff_corr_ric` with the das-focmec
60
+ signatures and identical results, so `das_focmec.processing.workflows` only changes its
61
+ import line.
62
+
63
+ ## What it does not do
64
+
65
+ * It does not re-pick. The initial curve decides which arrival and roughly which lobe is
66
+ refined; MCCC measures relative delays within `pair_slope` samples per channel of it.
67
+ * The anchor moves the whole curve by one offset measured on the stack (secondary phases
68
+ inherit their parent's). The first-lobe rule sits about 5 ms before the human pick for P
69
+ and within a few ms of it for S on the CAPE 2025 fibres, with a per-fibre constant;
70
+ calibrate it per site against a few human picks when onsets are needed.
71
+ * Sub-sample precision: the alignment is integer; tau is a float but the returned curve
72
+ inherits the integer initial alignment plus the smoothed tau.
73
+
74
+ ## Development
75
+
76
+ ```
77
+ PYTHONPATH=src pytest -q
78
+ ruff check src tests && ruff format --check src tests
79
+ ```
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "das-mccc"
7
+ version = "0.2.0"
8
+ description = "Iterative network MCCC refinement of DAS arrival curves with absolute anchoring and Ricker polarity QC"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Jaewoo Kim", email = "jk103@rice.edu"}]
14
+ keywords = ["DAS", "distributed acoustic sensing", "microseismic", "cross-correlation", "phase picking"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Physics",
23
+ ]
24
+ dependencies = ["numpy", "scipy"]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/Jaewoo-Kim-Rice/das-mccc"
28
+ Algorithm = "https://github.com/Jaewoo-Kim-Rice/das-mccc/blob/main/docs/algorithm.md"
29
+
30
+ [project.optional-dependencies]
31
+ numba = ["numba"]
32
+ dev = ["pytest", "ruff"]
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py310"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "B", "UP"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: das-mccc
3
+ Version: 0.2.0
4
+ Summary: Iterative network MCCC refinement of DAS arrival curves with absolute anchoring and Ricker polarity QC
5
+ Author-email: Jaewoo Kim <jk103@rice.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/Jaewoo-Kim-Rice/das-mccc
8
+ Project-URL: Algorithm, https://github.com/Jaewoo-Kim-Rice/das-mccc/blob/main/docs/algorithm.md
9
+ Keywords: DAS,distributed acoustic sensing,microseismic,cross-correlation,phase picking
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: Physics
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy
21
+ Requires-Dist: scipy
22
+ Provides-Extra: numba
23
+ Requires-Dist: numba; extra == "numba"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # das-mccc
30
+
31
+ Refine a DAS arrival curve with an iterative network multi-channel cross-correlation
32
+ (MCCC), anchor its absolute level on the aligned stack, and return per-channel polarity
33
+ and quality measures. Arrays in, arrays out: no site, file or path concepts.
34
+
35
+ The core is the refiner of [das-focmec](https://github.com/Jaewoo-Kim-Rice/das-focmec)
36
+ (`das_focmec.processing.mccc_core.ultra_mccc_iterative`), extracted with its history so it
37
+ can be used by any picker. Started from a rough curve (a VLM trace, a bracket, a
38
+ theoretical moveout) it recovers the shape of the arrival to the precision of a human
39
+ curve: on 24 CAPE 2025 reads the shape MAD against human picks went from 2.2 to 1.95 ms
40
+ (P) and 4.5 to 4.15 ms (S), and a curve started on the wrong lobe went from 5.6 to 2.65 ms.
41
+
42
+ ## Install
43
+
44
+ ```
45
+ pip install -e . # numpy, scipy
46
+ pip install -e '.[numba]' # fast pairwise correlation (strongly recommended)
47
+ pip install -e '.[dev]' # pytest, ruff
48
+ ```
49
+
50
+ Without numba the pairwise correlation runs in pure numpy: identical results, one to two
51
+ orders of magnitude slower, and a warning is logged at import.
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ import numpy as np
57
+ from dasmccc import refine_curve, refine_phases, DIRECT, SECONDARY
58
+
59
+ # waveform: (n_channels, n_samples) float, filtered as you like
60
+ # curve : (n_channels,) arrival in samples, NaN where the phase is not picked
61
+ res = refine_curve(waveform, curve, DIRECT)
62
+
63
+ res.curve # refined arrival (samples), NaN where the input was NaN
64
+ res.curve_relative # same shape, at the initial curve's level (no anchor)
65
+ res.anchor_offset # samples added by the anchor rule (NaN if the rule refused)
66
+ res.polarity # -1 / 0 / +1 per channel
67
+ res.snr, res.coherence, res.kept
68
+ res.aligned, res.stack # the aligned window and its stack, for plots
69
+
70
+ # several phases of one gather, strongest first; refined phases are masked for the next and a
71
+ # narrow tapered pre-mask (+-40 samples, 10 taper) keeps them out of the correlation
72
+ out = refine_phases(waveform, {"S": s_curve, "P": p_curve, "SP": sp_curve})
73
+ # several curves of one tag: any keys, plus a key -> tag map
74
+ out = refine_phases(waveform, {"S": s_curve, "R1": r1, "R2": r2},
75
+ tags={"S": "S", "R1": "REFL", "R2": "REFL"})
76
+ ```
77
+
78
+ Settings live in `RefineConfig` (everything in samples and channels). `DIRECT` is the
79
+ das-focmec configuration for direct waves (window 200, corr_len 200, smoothness 50, four
80
+ passes, pre-mask 100); `SECONDARY` narrows it for conversions and reflections (window
81
+ 120, corr_len 100, three passes, pre-mask 50). Both were calibrated at 1 kHz and 2 m channel
82
+ spacing; `docs/algorithm.md` gives the conversion to other rates and spacings, what each
83
+ knob does, and the anchoring and masking rules.
84
+
85
+ ## For das-focmec
86
+
87
+ `dasmccc.legacy` exposes `ultra_mccc_iterative` and `diff_corr_ric` with the das-focmec
88
+ signatures and identical results, so `das_focmec.processing.workflows` only changes its
89
+ import line.
90
+
91
+ ## What it does not do
92
+
93
+ * It does not re-pick. The initial curve decides which arrival and roughly which lobe is
94
+ refined; MCCC measures relative delays within `pair_slope` samples per channel of it.
95
+ * The anchor moves the whole curve by one offset measured on the stack (secondary phases
96
+ inherit their parent's). The first-lobe rule sits about 5 ms before the human pick for P
97
+ and within a few ms of it for S on the CAPE 2025 fibres, with a per-fibre constant;
98
+ calibrate it per site against a few human picks when onsets are needed.
99
+ * Sub-sample precision: the alignment is integer; tau is a float but the returned curve
100
+ inherits the integer initial alignment plus the smoothed tau.
101
+
102
+ ## Development
103
+
104
+ ```
105
+ PYTHONPATH=src pytest -q
106
+ ruff check src tests && ruff format --check src tests
107
+ ```
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/das_mccc.egg-info/PKG-INFO
5
+ src/das_mccc.egg-info/SOURCES.txt
6
+ src/das_mccc.egg-info/dependency_links.txt
7
+ src/das_mccc.egg-info/requires.txt
8
+ src/das_mccc.egg-info/top_level.txt
9
+ src/dasmccc/__init__.py
10
+ src/dasmccc/anchor.py
11
+ src/dasmccc/core.py
12
+ src/dasmccc/legacy.py
13
+ src/dasmccc/ops.py
14
+ src/dasmccc/pipeline.py
15
+ src/dasmccc/polarity.py
16
+ src/dasmccc/signal.py
17
+ tests/test_core.py
18
+ tests/test_legacy.py
19
+ tests/test_pipeline.py
@@ -0,0 +1,9 @@
1
+ numpy
2
+ scipy
3
+
4
+ [dev]
5
+ pytest
6
+ ruff
7
+
8
+ [numba]
9
+ numba
@@ -0,0 +1 @@
1
+ dasmccc
@@ -0,0 +1,45 @@
1
+ """dasmccc: iterative network MCCC refinement of DAS arrival curves.
2
+
3
+ from dasmccc import refine_curve, DIRECT
4
+ res = refine_curve(waveform, curve, DIRECT) # arrays in, RefineResult out
5
+ """
6
+
7
+ __version__ = "0.2.0"
8
+
9
+ from .anchor import first_lobe, stack_peak
10
+ from .core import iterate_align, mccc, pairwise_lags, partner_pairs, solve_tau
11
+ from .pipeline import (
12
+ DIRECT,
13
+ SECONDARY,
14
+ NothingToRefine,
15
+ RefineConfig,
16
+ RefineResult,
17
+ refine_curve,
18
+ refine_phases,
19
+ )
20
+ from .polarity import PolarityConfig, PolarityResult, RickerWindows, ricker_polarity, ricker_windows
21
+ from .signal import NUMBA_AVAILABLE
22
+
23
+ __all__ = [
24
+ "DIRECT",
25
+ "__version__",
26
+ "NUMBA_AVAILABLE",
27
+ "NothingToRefine",
28
+ "SECONDARY",
29
+ "PolarityConfig",
30
+ "PolarityResult",
31
+ "RefineConfig",
32
+ "RefineResult",
33
+ "RickerWindows",
34
+ "first_lobe",
35
+ "iterate_align",
36
+ "mccc",
37
+ "pairwise_lags",
38
+ "partner_pairs",
39
+ "refine_curve",
40
+ "refine_phases",
41
+ "ricker_polarity",
42
+ "ricker_windows",
43
+ "solve_tau",
44
+ "stack_peak",
45
+ ]
@@ -0,0 +1,79 @@
1
+ """Absolute anchoring of a relatively aligned gather.
2
+
3
+ Network MCCC fixes only relative delays; the level of the refined curve is whatever the
4
+ initial curve's level was (the lobe the initial picker traced). The rules here measure one
5
+ offset on the aligned stack so that the curve is moved to a reproducible feature of the
6
+ wavelet. All offsets are in samples relative to ``centre`` (the alignment sample).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ import numpy as np
14
+
15
+ log = logging.getLogger("dasmccc")
16
+
17
+
18
+ def stack_peak(stack: np.ndarray, centre: int) -> float:
19
+ """Offset of the |stack| maximum. Reproducible on strong reads, but the peak lobe is
20
+ not the same lobe on every read (spread of tens of ms against human onsets)."""
21
+ return float(int(np.argmax(np.abs(stack))) - centre)
22
+
23
+
24
+ def first_lobe(
25
+ stack: np.ndarray,
26
+ centre: int,
27
+ min_frac: float = 0.4,
28
+ guard: int | None = 40,
29
+ contiguous: bool = True,
30
+ window: tuple[int, int] | None = (-30, 10),
31
+ ) -> float:
32
+ """Offset of the centre of the first lobe of the stack.
33
+
34
+ Candidates are the local maxima of |stack|. ``window`` = (lo, hi) restricts the search to
35
+ ``centre + lo .. centre + hi`` (samples): the prior that the initial picker traced a lobe
36
+ of the arrival, so the onset lies at most one wavelet before it and hardly after it. The
37
+ reference amplitude is the |stack| peak inside the window. With ``contiguous`` the rule
38
+ walks back from that peak lobe by lobe while each lobe keeps at least ``min_frac`` of the
39
+ peak and returns the earliest lobe of that run (a precursor separated by a weaker lobe is
40
+ not the onset); without it the earliest candidate above ``min_frac`` anywhere before the
41
+ peak is taken (the original rule). The peak itself is returned when no earlier lobe
42
+ qualifies.
43
+
44
+ Tuned on 616 CAPE 2025 reads with human picks (das-phase-agent research record,
45
+ `docs/11_anchor_tuning.md`): window (-30, 10), contiguous, min_frac 0.4 on the
46
+ channel-normalised stack removed every refusal and halved the gross anchor errors
47
+ against the original rule (window None, contiguous False, min_frac 0.3, plain stack).
48
+
49
+ ``guard`` bounds |offset|: a larger offset is judged unreliable, a warning is logged and
50
+ NaN is returned so the caller keeps the relative level.
51
+ """
52
+ a = np.abs(np.asarray(stack, float))
53
+ n = len(a)
54
+ if window is None:
55
+ lo, hi = 0, n
56
+ else:
57
+ lo, hi = max(0, centre + int(window[0])), min(n, centre + int(window[1]) + 1)
58
+ if hi - lo < 3:
59
+ raise ValueError(f"anchor window {window} leaves no samples around centre {centre}")
60
+ pk = lo + int(np.argmax(a[lo:hi]))
61
+ ext = [i for i in range(max(1, lo), pk) if a[i] >= a[i - 1] and a[i] >= a[i + 1]]
62
+ first = pk
63
+ if contiguous:
64
+ for i in reversed(ext):
65
+ if a[i] >= min_frac * a[pk]:
66
+ first = i
67
+ else:
68
+ break
69
+ else:
70
+ ok = [i for i in ext if a[i] >= min_frac * a[pk]]
71
+ if ok:
72
+ first = ok[0]
73
+ offset = float(first - centre)
74
+ if guard is not None and abs(offset) > guard:
75
+ log.warning(
76
+ "first_lobe anchor %+.0f samples exceeds guard %d; anchor not applied", offset, guard
77
+ )
78
+ return float("nan")
79
+ return offset