detectability 0.4.0__tar.gz → 0.6.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.
- {detectability-0.4.0/src/detectability.egg-info → detectability-0.6.0}/PKG-INFO +36 -5
- {detectability-0.4.0 → detectability-0.6.0}/README.md +33 -4
- {detectability-0.4.0 → detectability-0.6.0}/pyproject.toml +3 -1
- detectability-0.6.0/src/detectability/__init__.py +9 -0
- detectability-0.6.0/src/detectability/data/ert_reference.json +40 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/detect.py +1 -1
- detectability-0.6.0/src/detectability/ert.py +272 -0
- detectability-0.6.0/src/detectability/limits.py +121 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/survey.py +45 -4
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/validation.py +31 -0
- {detectability-0.4.0 → detectability-0.6.0/src/detectability.egg-info}/PKG-INFO +36 -5
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/SOURCES.txt +6 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/requires.txt +3 -0
- detectability-0.6.0/tests/test_ert.py +61 -0
- detectability-0.6.0/tests/test_ert_pygimli.py +19 -0
- detectability-0.6.0/tests/test_limits.py +93 -0
- detectability-0.4.0/src/detectability/__init__.py +0 -7
- {detectability-0.4.0 → detectability-0.6.0}/LICENSE +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/setup.cfg +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/constants.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/data/harmonica_reference.json +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/data/igrf14coeffs.txt +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/gpr.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/gravity.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/igrf.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/magnetics.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability/rocks.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/dependency_links.txt +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/top_level.txt +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_detect.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_gpr.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_gravity.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_igrf.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_magnetics.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_survey.py +0 -0
- {detectability-0.4.0 → detectability-0.6.0}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detectability
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets.
|
|
5
5
|
Author: Mükremin Nergiz
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -30,6 +30,8 @@ Provides-Extra: test
|
|
|
30
30
|
Requires-Dist: pytest>=7; extra == "test"
|
|
31
31
|
Provides-Extra: validation
|
|
32
32
|
Requires-Dist: harmonica==0.7.0; extra == "validation"
|
|
33
|
+
Provides-Extra: ert
|
|
34
|
+
Requires-Dist: pygimli>=1.5; extra == "ert"
|
|
33
35
|
Dynamic: license-file
|
|
34
36
|
|
|
35
37
|
# Detectability
|
|
@@ -69,7 +71,7 @@ print(report.summary())
|
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
```text
|
|
72
|
-
Detectability 0.
|
|
74
|
+
Detectability 0.6.0: sphere in limestone
|
|
73
75
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
74
76
|
stations every 5 m or less, profile at least 153 m long
|
|
75
77
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -79,6 +81,29 @@ Recommended method: gravity
|
|
|
79
81
|
`report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
|
|
80
82
|
including the modelled profiles and the assumptions.
|
|
81
83
|
|
|
84
|
+
### How deep before a method loses it?
|
|
85
|
+
|
|
86
|
+
`depth_limit()` asks the inverse question — the one a survey plan turns on. Leave `depth_top` out: it is what the
|
|
87
|
+
search solves for.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from detectability import depth_limit
|
|
91
|
+
|
|
92
|
+
for method in ("gravity", "magnetic", "gpr"):
|
|
93
|
+
print(depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
|
|
94
|
+
{"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method=method))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
gravity: detectable (S/N >= 5) down to 25.7 m
|
|
99
|
+
magnetic: never reaches S/N 5; S/N 2.88 even at 0.5 m
|
|
100
|
+
gpr: detectable (S/N >= 5) down to 25.2 m
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Pass `threshold=detect.MARGINAL` for the lower threshold, and `search=(shallow, deep)` to bound the sweep.
|
|
104
|
+
Sweep the target size and you have a detection-limit chart; the website draws one at
|
|
105
|
+
[/limits](https://detectability.mukreminnergiz.com/limits).
|
|
106
|
+
|
|
82
107
|
Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
|
|
83
108
|
geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
|
|
84
109
|
in, and measured contrasts can be given instead.
|
|
@@ -87,10 +112,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
|
|
|
87
112
|
(lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
|
|
88
113
|
FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
89
114
|
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
90
|
-
recording window
|
|
115
|
+
recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
|
|
116
|
+
under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
|
|
117
|
+
apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
|
|
118
|
+
[pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
|
|
91
119
|
|
|
92
120
|
```python
|
|
93
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
121
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
|
|
94
122
|
```
|
|
95
123
|
|
|
96
124
|
## Modules
|
|
@@ -101,6 +129,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
|
101
129
|
| `gravity` | sphere, horizontal cylinder, right rectangular prism (Nagy; Plouff) and its gradient tensor, vertical cylinder (exact, by a rim integral) and its gradient tensor, vertical step, dyke, 2D polygon (Talwani; Won & Bevis) |
|
|
102
130
|
| `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation (a gradiometer reading is the difference at two sensor heights) |
|
|
103
131
|
| `gpr` | ground-penetrating radar: exact velocity and attenuation of a lossy medium, normal-incidence reflection coefficient, target-size factor (geometric optics, Rayleigh regime, Fresnel zone), echo against system performance; typical permittivity and conductivity (Davis & Annan 1989) |
|
|
132
|
+
| `ert` | electrical resistivity tomography with pyGIMLi (optional): electrode layout, 2D finite-element forward model, inversion and recovery check; typical resistivities (Loke 1999, after Keller & Frischknecht 1966) |
|
|
104
133
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
105
134
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
106
135
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -145,6 +174,8 @@ python tools/harmonica_reference.py --check
|
|
|
145
174
|
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
146
175
|
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
147
176
|
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
177
|
+
| Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
|
|
178
|
+
| Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
|
|
148
179
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
149
180
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
150
181
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -172,7 +203,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
172
203
|
|
|
173
204
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
174
205
|
|
|
175
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
206
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
|
|
176
207
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
177
208
|
|
|
178
209
|
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
@@ -35,7 +35,7 @@ print(report.summary())
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
```text
|
|
38
|
-
Detectability 0.
|
|
38
|
+
Detectability 0.6.0: sphere in limestone
|
|
39
39
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
40
40
|
stations every 5 m or less, profile at least 153 m long
|
|
41
41
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -45,6 +45,29 @@ Recommended method: gravity
|
|
|
45
45
|
`report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
|
|
46
46
|
including the modelled profiles and the assumptions.
|
|
47
47
|
|
|
48
|
+
### How deep before a method loses it?
|
|
49
|
+
|
|
50
|
+
`depth_limit()` asks the inverse question — the one a survey plan turns on. Leave `depth_top` out: it is what the
|
|
51
|
+
search solves for.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from detectability import depth_limit
|
|
55
|
+
|
|
56
|
+
for method in ("gravity", "magnetic", "gpr"):
|
|
57
|
+
print(depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
|
|
58
|
+
{"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method=method))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
gravity: detectable (S/N >= 5) down to 25.7 m
|
|
63
|
+
magnetic: never reaches S/N 5; S/N 2.88 even at 0.5 m
|
|
64
|
+
gpr: detectable (S/N >= 5) down to 25.2 m
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Pass `threshold=detect.MARGINAL` for the lower threshold, and `search=(shallow, deep)` to bound the sweep.
|
|
68
|
+
Sweep the target size and you have a detection-limit chart; the website draws one at
|
|
69
|
+
[/limits](https://detectability.mukreminnergiz.com/limits).
|
|
70
|
+
|
|
48
71
|
Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
|
|
49
72
|
geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
|
|
50
73
|
in, and measured contrasts can be given instead.
|
|
@@ -53,10 +76,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
|
|
|
53
76
|
(lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
|
|
54
77
|
FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
55
78
|
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
56
|
-
recording window
|
|
79
|
+
recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
|
|
80
|
+
under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
|
|
81
|
+
apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
|
|
82
|
+
[pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
|
|
57
83
|
|
|
58
84
|
```python
|
|
59
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
85
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
|
|
60
86
|
```
|
|
61
87
|
|
|
62
88
|
## Modules
|
|
@@ -67,6 +93,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
|
67
93
|
| `gravity` | sphere, horizontal cylinder, right rectangular prism (Nagy; Plouff) and its gradient tensor, vertical cylinder (exact, by a rim integral) and its gradient tensor, vertical step, dyke, 2D polygon (Talwani; Won & Bevis) |
|
|
68
94
|
| `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation (a gradiometer reading is the difference at two sensor heights) |
|
|
69
95
|
| `gpr` | ground-penetrating radar: exact velocity and attenuation of a lossy medium, normal-incidence reflection coefficient, target-size factor (geometric optics, Rayleigh regime, Fresnel zone), echo against system performance; typical permittivity and conductivity (Davis & Annan 1989) |
|
|
96
|
+
| `ert` | electrical resistivity tomography with pyGIMLi (optional): electrode layout, 2D finite-element forward model, inversion and recovery check; typical resistivities (Loke 1999, after Keller & Frischknecht 1966) |
|
|
70
97
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
71
98
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
72
99
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -111,6 +138,8 @@ python tools/harmonica_reference.py --check
|
|
|
111
138
|
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
112
139
|
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
113
140
|
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
141
|
+
| Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
|
|
142
|
+
| Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
|
|
114
143
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
115
144
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
116
145
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -138,7 +167,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
138
167
|
|
|
139
168
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
140
169
|
|
|
141
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
170
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
|
|
142
171
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
143
172
|
|
|
144
173
|
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "detectability"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = ["geophysics", "gravity", "magnetics", "survey design", "near-surface", "forward modelling", "IGRF"]
|
|
@@ -38,6 +38,8 @@ Citation = "https://doi.org/10.5281/zenodo.23931815"
|
|
|
38
38
|
test = ["pytest>=7"]
|
|
39
39
|
# Recomputes the "Independent code" reference values (tools/harmonica_reference.py); not needed to use the library.
|
|
40
40
|
validation = ["harmonica==0.7.0"]
|
|
41
|
+
# Electrical resistivity tomography (method "ert"): finite elements and inversion with pyGIMLi.
|
|
42
|
+
ert = ["pygimli>=1.5"]
|
|
41
43
|
|
|
42
44
|
[tool.setuptools.packages.find]
|
|
43
45
|
where = ["src"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Detectability: pre-survey detectability, modelled and reproducible."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.6.0"
|
|
4
|
+
|
|
5
|
+
from .limits import Limit, depth_limit # noqa: E402
|
|
6
|
+
from .survey import DEFAULT_METHODS, METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
|
|
7
|
+
|
|
8
|
+
__all__ = ["evaluate", "depth_limit", "Limit", "Target", "Survey", "Report",
|
|
9
|
+
"SHAPES", "METHODS", "DEFAULT_METHODS", "__version__"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pygimli": "1.6.1",
|
|
3
|
+
"doi": "10.1016/j.cageo.2017.07.011",
|
|
4
|
+
"halfspace_error_percent": 0.29701507707668995,
|
|
5
|
+
"wenner_two_layer": {
|
|
6
|
+
"rho1": 100.0,
|
|
7
|
+
"rho2": 10.0,
|
|
8
|
+
"h": 5.0,
|
|
9
|
+
"spacing": [
|
|
10
|
+
1.0,
|
|
11
|
+
2.0,
|
|
12
|
+
3.0,
|
|
13
|
+
5.0,
|
|
14
|
+
8.0,
|
|
15
|
+
12.0,
|
|
16
|
+
16.0,
|
|
17
|
+
20.0
|
|
18
|
+
],
|
|
19
|
+
"model": [
|
|
20
|
+
99.61000261515714,
|
|
21
|
+
96.89634932280532,
|
|
22
|
+
91.18162883033095,
|
|
23
|
+
73.36750131129314,
|
|
24
|
+
46.680818102896644,
|
|
25
|
+
25.39363222503698,
|
|
26
|
+
16.284202471814222,
|
|
27
|
+
12.780339615225937
|
|
28
|
+
],
|
|
29
|
+
"analytic": [
|
|
30
|
+
99.56748456281315,
|
|
31
|
+
96.90460006182809,
|
|
32
|
+
91.1609264135159,
|
|
33
|
+
73.39044630419616,
|
|
34
|
+
46.53753517471967,
|
|
35
|
+
25.330265325884227,
|
|
36
|
+
16.376777534327324,
|
|
37
|
+
12.860338899308598
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -76,7 +76,7 @@ DEFAULT_FIELD_NOISE_NT = 2.0 # quiet rural site after diurnal correction; ur
|
|
|
76
76
|
# by less than about 0.5 nT over 1 m); change it for the site.
|
|
77
77
|
DEFAULT_FIELD_NOISE_GRADIENT_NT = 0.5
|
|
78
78
|
|
|
79
|
-
UNITS = {"gravity": "µGal", "magnetic": "nT", "gradient": "nT", "gpr": "× detection limit"}
|
|
79
|
+
UNITS = {"gravity": "µGal", "magnetic": "nT", "gradient": "nT", "gpr": "× detection limit", "ert": "%"}
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def effective_noise(instrument: float, field_noise: float) -> float:
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Electrical resistivity tomography (ERT): 2D forward model of a target under a line of electrodes, the change it
|
|
2
|
+
makes to the apparent resistivities, and whether an inversion of noisy synthetic data recovers it.
|
|
3
|
+
|
|
4
|
+
The finite-element modelling and the inversion use pyGIMLi (Rücker, Günther & Wagner 2017, Computers & Geosciences
|
|
5
|
+
109, 106-123), an optional dependency: ``pip install "detectability[ert]"``. Without it the method is reported as not
|
|
6
|
+
available. A service can run it instead: if pyGIMLi is missing and ``DETECTABILITY_ERT_URL`` is set, the request is
|
|
7
|
+
sent there (the website does this; its ERT runs on a separate machine).
|
|
8
|
+
|
|
9
|
+
What is computed, and the conventions:
|
|
10
|
+
- Electrodes at equal spacing along a straight line centred over the target; arrays: dipole-dipole, Wenner,
|
|
11
|
+
Schlumberger or multiple gradient.
|
|
12
|
+
- Apparent resistivities with and without the target on the same mesh; their ratio removes the mesh error. The data
|
|
13
|
+
anomaly is 100 (rho_a,target / rho_a,host - 1) %, and S/N is its largest absolute value over the data noise (%), with
|
|
14
|
+
the same thresholds as the other methods.
|
|
15
|
+
- Recovery: the noisy data (the given noise level) are inverted (smoothness-constrained Gauss-Newton, pyGIMLi's
|
|
16
|
+
ERTManager, at most 6 iterations). The recovered contrast is the median model value inside the target over the median
|
|
17
|
+
outside. The target counts as recovered when that contrast has the right sign and reaches 20 % of the true contrast
|
|
18
|
+
in log scale or a factor of 1.5, whichever is smaller; otherwise a detectable verdict is lowered to marginal.
|
|
19
|
+
- 2D: the target is infinitely long across the profile. A sphere is modelled as a horizontal cylinder of the same
|
|
20
|
+
diameter, which overstates its effect; the assumptions say so.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import urllib.request
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
# Resistivity ranges (ohm m), from Loke (1999), "Electrical imaging surveys for environmental and engineering
|
|
34
|
+
# studies", Table 1, after Keller & Frischknecht (1966) and Daniels & Alberty (1966). "Typical" is the geometric
|
|
35
|
+
# middle of the range. Air (a void) is effectively an insulator; it is modelled as 10^6 ohm m, beyond which the
|
|
36
|
+
# anomaly no longer changes. Metal: iron, 9.074e-8 ohm m.
|
|
37
|
+
RESISTIVITY: dict[str, tuple[float, float, float]] = {
|
|
38
|
+
"air": (1e6, 1e6, 1e6),
|
|
39
|
+
"granite": (7.07e4, 5e3, 1e6),
|
|
40
|
+
"basalt": (3.16e4, 1e3, 1e6),
|
|
41
|
+
"slate": (1.55e5, 6e2, 4e7),
|
|
42
|
+
"marble": (1.58e5, 1e2, 2.5e8),
|
|
43
|
+
"quartzite": (1.41e5, 1e2, 2e8),
|
|
44
|
+
"sandstone": (1.79e2, 8.0, 4e3),
|
|
45
|
+
"shale": (2.0e2, 20.0, 2e3),
|
|
46
|
+
"limestone": (1.41e2, 50.0, 4e2),
|
|
47
|
+
"clay": (10.0, 1.0, 100.0),
|
|
48
|
+
"alluvium": (89.4, 10.0, 800.0),
|
|
49
|
+
"water": (31.6, 10.0, 100.0),
|
|
50
|
+
"fresh water": (31.6, 10.0, 100.0),
|
|
51
|
+
"sea water": (0.2, 0.2, 0.2),
|
|
52
|
+
"metal": (9.074e-8, 9.074e-8, 9.074e-8),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ARRAYS = {"dipole-dipole": "dd", "wenner": "wa", "schlumberger": "slm", "gradient": "gr"}
|
|
56
|
+
DEFAULT_ARRAY = "dipole-dipole"
|
|
57
|
+
DEFAULT_ELECTRODES = 48
|
|
58
|
+
DEFAULT_NOISE_PERCENT = 3.0
|
|
59
|
+
RECOVERY_THRESHOLD = 0.2
|
|
60
|
+
RECOVERED_FACTOR = 1.5
|
|
61
|
+
MIN_RESISTIVITY = 1e-3 # metal is modelled as 10^-3 ohm m (a perfect conductor for the survey; avoids a singular system)
|
|
62
|
+
DEFAULT_SERVICE_TIMEOUT = 150
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resistivity(name: str) -> float:
|
|
66
|
+
try:
|
|
67
|
+
return RESISTIVITY[name][0]
|
|
68
|
+
except KeyError:
|
|
69
|
+
raise KeyError(f"no resistivity for {name!r}; known: {', '.join(sorted(RESISTIVITY))}") from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def available() -> bool:
|
|
73
|
+
try:
|
|
74
|
+
import pygimli # noqa: F401
|
|
75
|
+
return True
|
|
76
|
+
except ImportError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _nice_down(v: float) -> float:
|
|
81
|
+
e = np.floor(np.log10(v))
|
|
82
|
+
for m in (5.0, 2.0, 1.0):
|
|
83
|
+
if m * 10**e <= v:
|
|
84
|
+
return float(m * 10**e)
|
|
85
|
+
return float(5 * 10 ** (e - 1))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_spacing(depth_needed: float, electrodes: int) -> float:
|
|
89
|
+
"""Electrode spacing whose spread (electrodes - 1 spacings) is about six times the depth that must be seen."""
|
|
90
|
+
return max(0.1, _nice_down(6.0 * depth_needed / (electrodes - 1)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Geometry:
|
|
95
|
+
polygon: list # target outline (x, z), z negative downwards, in the profile frame centred on the target
|
|
96
|
+
depth_needed: float # m: the bottom of the target (or of its useful part) that the survey must reach
|
|
97
|
+
area: float # m^2 of the 2D outline
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def geometry(t) -> Geometry:
|
|
101
|
+
"""2D outline of a ``survey.Target``."""
|
|
102
|
+
r = (t.size or 0) / 2
|
|
103
|
+
if t.shape in ("sphere", "horizontal_cylinder"):
|
|
104
|
+
zc = t.depth_top + r
|
|
105
|
+
ang = np.linspace(0, 2 * np.pi, 48, endpoint=False)
|
|
106
|
+
return Geometry([(r * np.cos(a), -zc + r * np.sin(a)) for a in ang], zc + r, np.pi * r * r)
|
|
107
|
+
if t.shape in ("vertical_cylinder", "prism"):
|
|
108
|
+
w = t.size / 2
|
|
109
|
+
return Geometry([(-w, -t.depth_top), (w, -t.depth_top), (w, -t.depth_bottom), (-w, -t.depth_bottom)],
|
|
110
|
+
t.depth_bottom, 2 * w * (t.depth_bottom - t.depth_top))
|
|
111
|
+
if t.shape == "dyke":
|
|
112
|
+
h = t.depth_bottom - t.depth_top
|
|
113
|
+
dx = h / np.tan(np.radians(t.dip)) if abs(t.dip - 90) > 1e-9 else 0.0
|
|
114
|
+
w = t.size / 2
|
|
115
|
+
return Geometry([(-w, -t.depth_top), (w, -t.depth_top), (w + dx, -t.depth_bottom), (-w + dx, -t.depth_bottom)],
|
|
116
|
+
t.depth_bottom, t.size * h)
|
|
117
|
+
if t.shape == "contact":
|
|
118
|
+
return Geometry([], t.depth_bottom, float("inf"))
|
|
119
|
+
raise ValueError(f"unknown shape {t.shape!r}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run(t, s) -> dict:
|
|
123
|
+
"""Model, assess and invert. Returns a plain dict (MethodResult fields) so a service can send it as JSON."""
|
|
124
|
+
import pygimli as pg
|
|
125
|
+
import pygimli.meshtools as mt
|
|
126
|
+
from pygimli.physics import ert as pgert
|
|
127
|
+
|
|
128
|
+
from . import detect
|
|
129
|
+
|
|
130
|
+
pg.setThreadCount(int(os.environ.get("DETECTABILITY_ERT_THREADS", "2")))
|
|
131
|
+
started = time.time()
|
|
132
|
+
rho_h = t.host_resistivity if t.host_resistivity is not None else resistivity(t.host)
|
|
133
|
+
rho_t = t.resistivity if t.resistivity is not None else resistivity(t.material)
|
|
134
|
+
rho_t = max(rho_t, MIN_RESISTIVITY)
|
|
135
|
+
if rho_h <= 0:
|
|
136
|
+
raise ValueError("host resistivity must be positive")
|
|
137
|
+
array = s.ert_array or DEFAULT_ARRAY
|
|
138
|
+
if array not in ARRAYS:
|
|
139
|
+
raise ValueError(f"unknown ERT array {array!r}; choose from {', '.join(ARRAYS)}")
|
|
140
|
+
n = int(s.ert_electrodes or DEFAULT_ELECTRODES)
|
|
141
|
+
if not 8 <= n <= 96:
|
|
142
|
+
raise ValueError("ert_electrodes must be between 8 and 96")
|
|
143
|
+
noise = s.ert_noise if s.ert_noise is not None else DEFAULT_NOISE_PERCENT
|
|
144
|
+
if not 0.1 <= noise <= 50:
|
|
145
|
+
raise ValueError("ert_noise must be between 0.1 and 50 %")
|
|
146
|
+
g = geometry(t)
|
|
147
|
+
a = s.ert_spacing or default_spacing(g.depth_needed, n)
|
|
148
|
+
if a <= 0:
|
|
149
|
+
raise ValueError("ert_spacing must be positive")
|
|
150
|
+
L = a * (n - 1)
|
|
151
|
+
x0 = -L / 2
|
|
152
|
+
elecs = np.linspace(x0, x0 + L, n)
|
|
153
|
+
scheme = pgert.createData(elecs=elecs, schemeName=ARRAYS[array])
|
|
154
|
+
|
|
155
|
+
pad = max(L, 4 * g.depth_needed)
|
|
156
|
+
depth = max(L / 2, 2.5 * g.depth_needed)
|
|
157
|
+
world = mt.createWorld(start=[x0 - pad, 0], end=[x0 + L + pad, -depth - pad], worldMarker=True)
|
|
158
|
+
if t.shape == "contact":
|
|
159
|
+
body = mt.createPolygon([(0, -t.depth_top), (x0 + L + pad, -t.depth_top), (x0 + L + pad, -t.depth_bottom),
|
|
160
|
+
(0, -t.depth_bottom)], isClosed=True, marker=2)
|
|
161
|
+
else:
|
|
162
|
+
body = mt.createPolygon(g.polygon, isClosed=True, marker=2, area=max((a / 4) ** 2, g.area / 200))
|
|
163
|
+
geom = world + body
|
|
164
|
+
for p in scheme.sensors():
|
|
165
|
+
geom.createNode(p)
|
|
166
|
+
geom.createNode(p - [0, a / 10])
|
|
167
|
+
mesh = mt.createMesh(geom, quality=33.5)
|
|
168
|
+
|
|
169
|
+
with_target = pgert.simulate(mesh, scheme=scheme, res=[[1, rho_h], [2, rho_t]], noiseLevel=0, verbose=False)
|
|
170
|
+
host_only = pgert.simulate(mesh, scheme=scheme, res=[[1, rho_h], [2, rho_h]], noiseLevel=0, verbose=False)
|
|
171
|
+
ra_t, ra_h = np.array(with_target["rhoa"]), np.array(host_only["rhoa"])
|
|
172
|
+
anomaly = 100.0 * (ra_t / ra_h - 1.0)
|
|
173
|
+
|
|
174
|
+
# Profile for the assessment: at each data midpoint, the largest change over the levels.
|
|
175
|
+
mid = np.array([(elecs[int(with_target["a"][i])] + elecs[int(with_target["b"][i])] +
|
|
176
|
+
elecs[int(with_target["m"][i])] + elecs[int(with_target["n"][i])]) / 4
|
|
177
|
+
for i in range(with_target.size())])
|
|
178
|
+
xs = np.unique(np.round(mid, 6))
|
|
179
|
+
prof = np.array([anomaly[np.isclose(mid, xv)][np.argmax(np.abs(anomaly[np.isclose(mid, xv)]))] for xv in xs])
|
|
180
|
+
assessment = detect.assess(xs, prof, noise, depth=t.depth_top, method="ert")
|
|
181
|
+
assessment.station_spacing = float(a)
|
|
182
|
+
assessment.profile_length = float(L)
|
|
183
|
+
|
|
184
|
+
details = {"array": array, "electrodes": n, "spacing_m": float(a), "spread_m": float(L), "data": int(scheme.size()),
|
|
185
|
+
"host_resistivity": float(rho_h), "target_resistivity": float(rho_t), "noise_percent": float(noise),
|
|
186
|
+
"max_anomaly_percent": float(np.max(np.abs(anomaly))), "inverted": False}
|
|
187
|
+
|
|
188
|
+
if s.ert_invert and t.shape != "contact":
|
|
189
|
+
rng = np.random.default_rng(1337)
|
|
190
|
+
data = pg.DataContainerERT(with_target)
|
|
191
|
+
data["rhoa"] = ra_t * (1 + rng.normal(0, noise / 100, len(ra_t)))
|
|
192
|
+
data["err"] = pg.Vector(len(ra_t), noise / 100)
|
|
193
|
+
mgr = pgert.ERTManager(data, verbose=False)
|
|
194
|
+
mgr.invert(lam=20, verbose=False, paraDepth=min(depth, max(1.5 * g.depth_needed, L / 5)), quality=33.6,
|
|
195
|
+
paraMaxCellSize=max(a * a, g.area / 50), paraDX=0.5, maxIter=6)
|
|
196
|
+
cells = np.array(mgr.paraDomain.cellCenters())[:, :2]
|
|
197
|
+
model = np.array(mgr.model)
|
|
198
|
+
inside = np.array([_inside(p, g.polygon) for p in cells])
|
|
199
|
+
if inside.sum() >= 3:
|
|
200
|
+
rec_t, rec_bg = float(np.median(model[inside])), float(np.median(model[~inside]))
|
|
201
|
+
recovery = float(np.log(rec_t / rec_bg) / np.log(rho_t / rho_h)) if rho_t != rho_h else 0.0
|
|
202
|
+
else:
|
|
203
|
+
rec_t, rec_bg, recovery = None, float(np.median(model)), 0.0
|
|
204
|
+
# Recovered: the inverted contrast points the right way and reaches 20 % of the true contrast (log scale) or a
|
|
205
|
+
# factor of 1.5, whichever is smaller (a void's true contrast is huge; no inversion of surface data reaches it).
|
|
206
|
+
need = min(RECOVERY_THRESHOLD * abs(np.log(rho_t / rho_h)), np.log(RECOVERED_FACTOR))
|
|
207
|
+
recovered = bool(rec_t is not None and rho_t != rho_h and np.sign(np.log(rec_t / rec_bg)) == np.sign(np.log(rho_t / rho_h))
|
|
208
|
+
and abs(np.log(rec_t / rec_bg)) >= need)
|
|
209
|
+
details.update(inverted=True, recovered_target=rec_t, recovered_background=rec_bg, recovery=recovery,
|
|
210
|
+
recovered=recovered, chi2=float(mgr.inv.chi2()), inversion_cells=int(len(model)),
|
|
211
|
+
section={"x": cells[:, 0].round(3).tolist(), "z": cells[:, 1].round(3).tolist(),
|
|
212
|
+
"rho": np.round(model, 4).tolist()})
|
|
213
|
+
if not recovered and assessment.verdict != "not detectable":
|
|
214
|
+
assessment.verdict = "marginal"
|
|
215
|
+
details["seconds"] = round(time.time() - started, 1)
|
|
216
|
+
|
|
217
|
+
unit = "%"
|
|
218
|
+
asm = [
|
|
219
|
+
f"Signal-to-noise thresholds: >= {detect.CLEAR:g} clearly detectable, {detect.MARGINAL:g}-{detect.CLEAR:g} "
|
|
220
|
+
f"marginal, < {detect.MARGINAL:g} not detectable; S/N is the largest change in apparent resistivity over the "
|
|
221
|
+
f"data noise ({noise:g} %).",
|
|
222
|
+
f"{n} electrodes every {a:g} m ({L:g} m spread), {array} array, {scheme.size()} measurements.",
|
|
223
|
+
f"Host {rho_h:.3g} Ωm, target {rho_t:.3g} Ωm" + ("" if t.resistivity is not None or t.host_resistivity is not None
|
|
224
|
+
else " (typical values; resistivity ranges over orders of magnitude, measure on site)") + ".",
|
|
225
|
+
"2D model: the target is infinitely long across the profile; homogeneous host, flat ground, electrodes on the "
|
|
226
|
+
"surface. Finite-element forward model (pyGIMLi); the target/host ratio cancels the mesh error.",
|
|
227
|
+
]
|
|
228
|
+
if t.shape == "sphere":
|
|
229
|
+
asm.append("A sphere is modelled as a horizontal cylinder of the same diameter: a real 3D sphere gives a "
|
|
230
|
+
"smaller anomaly than shown.")
|
|
231
|
+
if t.shape in ("prism", "vertical_cylinder"):
|
|
232
|
+
asm.append("A 3D block or shaft is modelled by its 2D cross-section; a short target gives a smaller anomaly.")
|
|
233
|
+
if details["inverted"]:
|
|
234
|
+
rt = details["recovered_target"]
|
|
235
|
+
asm.append(f"Inversion of the noisy data (smoothness constraint, λ 20, χ² {details['chi2']:.2g}): recovered "
|
|
236
|
+
f"{'%.3g' % rt + ' Ωm in the target' if rt else 'no cells inside the target'} against "
|
|
237
|
+
f"{details['recovered_background']:.3g} Ωm around it ({100 * details['recovery']:.0f} % of the true contrast "
|
|
238
|
+
f"in log scale): " + ("recovered." if details["recovered"] else "not recovered (needs 20 % of the "
|
|
239
|
+
"contrast in log scale or a factor of 1.5)."))
|
|
240
|
+
elif t.shape == "contact":
|
|
241
|
+
asm.append("Inversion not run for a contact (no closed target).")
|
|
242
|
+
if a > (t.size or a):
|
|
243
|
+
asm.append(f"Electrode spacing ({a:g} m) is larger than the target ({t.size:g} m): its shape is not resolved.")
|
|
244
|
+
if t.shape in ("horizontal_cylinder", "dyke", "contact"):
|
|
245
|
+
asm.append("2D target: infinitely long along strike, at right angles to the profile.")
|
|
246
|
+
assessment.assumptions = asm
|
|
247
|
+
assessment.units = unit
|
|
248
|
+
return {"method": "ert", "available": True, "contrast": float(rho_t / rho_h), "contrast_units": "resistivity ratio",
|
|
249
|
+
"instrument": f"{n} electrodes, {array}", "instrument_noise": float(noise), "field_noise": None,
|
|
250
|
+
"x": xs.round(4).tolist(), "anomaly": prof.tolist(), "assessment": assessment.to_dict(), "details": details}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _inside(p, poly) -> bool:
|
|
254
|
+
x, z = p
|
|
255
|
+
inside = False
|
|
256
|
+
j = len(poly) - 1
|
|
257
|
+
for i in range(len(poly)):
|
|
258
|
+
xi, zi = poly[i]
|
|
259
|
+
xj, zj = poly[j]
|
|
260
|
+
if (zi > z) != (zj > z) and x < (xj - xi) * (z - zi) / (zj - zi) + xi:
|
|
261
|
+
inside = not inside
|
|
262
|
+
j = i
|
|
263
|
+
return inside
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def remote(target: dict, survey: dict, url: str) -> dict:
|
|
267
|
+
"""Ask an ERT service (another machine with pyGIMLi) for the result."""
|
|
268
|
+
req = urllib.request.Request(url, data=json.dumps({"target": target, "survey": survey}).encode(),
|
|
269
|
+
headers={"Content-Type": "application/json"})
|
|
270
|
+
timeout = float(os.environ.get("DETECTABILITY_ERT_TIMEOUT", DEFAULT_SERVICE_TIMEOUT))
|
|
271
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
272
|
+
return json.loads(r.read())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""How deep a method still sees a target: the depth limit, solved for instead of assumed.
|
|
2
|
+
|
|
3
|
+
``evaluate`` answers "can this method see the target at this depth?". The inverse question is the one that shapes a
|
|
4
|
+
survey plan: *how deep can the target be before the method loses it?*
|
|
5
|
+
|
|
6
|
+
>>> from detectability import depth_limit
|
|
7
|
+
>>> limit = depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
|
|
8
|
+
... {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method="gravity")
|
|
9
|
+
>>> print(limit)
|
|
10
|
+
|
|
11
|
+
``depth_top`` on the target is ignored, and may be left out entirely: it is what the search solves for. If the shape
|
|
12
|
+
also needs ``depth_bottom``, the body keeps its thickness as it moves down.
|
|
13
|
+
|
|
14
|
+
The search makes no assumption that signal-to-noise falls smoothly with depth, because it does not always: the GPR
|
|
15
|
+
antenna frequency, when left to the default, is chosen from the target depth, so the response can step. A coarse
|
|
16
|
+
geometric scan brackets the **shallowest** crossing of the threshold and a bisection refines it. The shallowest
|
|
17
|
+
crossing is the honest answer — beyond it the method can no longer be relied on, whatever happens deeper.
|
|
18
|
+
|
|
19
|
+
Each limit costs ``steps`` plus about twenty forward models. That is milliseconds for gravity, magnetics and GPR.
|
|
20
|
+
For ERT, where a single forward model inverts a mesh and takes tens of seconds, a limit takes tens of minutes; pass a
|
|
21
|
+
narrow ``search`` range and a small ``steps`` if you ask for one at all.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, replace
|
|
27
|
+
|
|
28
|
+
from . import detect
|
|
29
|
+
from .survey import NEEDS_BOTTOM, Survey, Target, _as, evaluate
|
|
30
|
+
|
|
31
|
+
__all__ = ["Limit", "depth_limit"]
|
|
32
|
+
|
|
33
|
+
SEARCH = (0.5, 2000.0) # m below ground: shallow enough for utilities, deep enough for an ore body
|
|
34
|
+
STEPS = 24 # coarse scan points, geometric over the search range
|
|
35
|
+
REFINE = 20 # bisections; takes a 24-point scan of 0.5-2000 m below 0.1 % in depth
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Limit:
|
|
40
|
+
"""The greatest depth at which ``method`` still reaches ``threshold`` for a given target."""
|
|
41
|
+
|
|
42
|
+
method: str
|
|
43
|
+
threshold: float
|
|
44
|
+
depth: float | None # m to the top of the target; None when the method never reaches the threshold
|
|
45
|
+
snr_shallowest: float | None # signal-to-noise at the shallowest depth searched, for context
|
|
46
|
+
search: tuple[float, float]
|
|
47
|
+
beyond_search: bool = False # still detected at the deepest depth searched: the limit is at least that
|
|
48
|
+
available: bool = True
|
|
49
|
+
reason: str = ""
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
if not self.available:
|
|
53
|
+
return f"{self.method}: not assessed ({self.reason})"
|
|
54
|
+
if self.depth is None:
|
|
55
|
+
return (f"{self.method}: never reaches S/N {self.threshold:g}; "
|
|
56
|
+
f"S/N {self.snr_shallowest:.3g} even at {self.search[0]:g} m")
|
|
57
|
+
at_least = "at least " if self.beyond_search else ""
|
|
58
|
+
return f"{self.method}: detectable (S/N >= {self.threshold:g}) down to {at_least}{self.depth:.3g} m"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _at(target: Target, depth: float) -> Target:
|
|
62
|
+
"""The same body, moved so its top sits at ``depth``; thickness kept where the shape needs a bottom."""
|
|
63
|
+
moved = replace(target, depth_top=depth)
|
|
64
|
+
if target.shape in NEEDS_BOTTOM:
|
|
65
|
+
thickness = (target.depth_bottom - target.depth_top) if target.depth_bottom else max(target.size or 1.0, 1.0)
|
|
66
|
+
moved.depth_bottom = depth + thickness
|
|
67
|
+
return moved
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def depth_limit(target, survey=None, method: str = "gravity", threshold: float = detect.CLEAR,
|
|
71
|
+
search: tuple[float, float] = SEARCH, steps: int = STEPS, refine: int = REFINE) -> Limit:
|
|
72
|
+
"""Greatest depth at which ``method`` still reaches ``threshold`` for ``target`` under ``survey``.
|
|
73
|
+
|
|
74
|
+
``threshold`` defaults to the clearly-detectable signal-to-noise (5); pass ``detect.MARGINAL`` for the lower one.
|
|
75
|
+
The result's ``depth`` is None when the method never reaches the threshold, even at the shallowest depth searched.
|
|
76
|
+
"""
|
|
77
|
+
shallow, deep = float(search[0]), float(search[1])
|
|
78
|
+
if not 0 < shallow < deep:
|
|
79
|
+
raise ValueError(f"search must be (shallow, deep) with 0 < shallow < deep; got {search!r}")
|
|
80
|
+
if steps < 2:
|
|
81
|
+
raise ValueError("steps must be at least 2")
|
|
82
|
+
|
|
83
|
+
if isinstance(target, dict) and "depth_top" not in target:
|
|
84
|
+
target = {**target, "depth_top": shallow} # it is what the search solves for; any value will do
|
|
85
|
+
t, s = _as(Target, target), _as(Survey, survey)
|
|
86
|
+
t.validate()
|
|
87
|
+
|
|
88
|
+
def snr(depth: float) -> tuple[float, bool, str]:
|
|
89
|
+
result = evaluate(_at(t, depth), s, methods=[method]).results[0]
|
|
90
|
+
if not result.available:
|
|
91
|
+
return 0.0, False, result.reason
|
|
92
|
+
return result.assessment.snr, True, ""
|
|
93
|
+
|
|
94
|
+
first, ok, why = snr(shallow)
|
|
95
|
+
if not ok:
|
|
96
|
+
return Limit(method, threshold, None, None, (shallow, deep), available=False, reason=why)
|
|
97
|
+
if first < threshold:
|
|
98
|
+
return Limit(method, threshold, None, first, (shallow, deep))
|
|
99
|
+
|
|
100
|
+
# Coarse scan for the shallowest crossing, then bisect inside that bracket.
|
|
101
|
+
ratio = (deep / shallow) ** (1 / (steps - 1))
|
|
102
|
+
lo, lo_depth = shallow, shallow
|
|
103
|
+
for i in range(1, steps):
|
|
104
|
+
probe = shallow * ratio ** i
|
|
105
|
+
value, ok, _ = snr(probe)
|
|
106
|
+
if not ok or value < threshold:
|
|
107
|
+
hi = probe
|
|
108
|
+
break
|
|
109
|
+
lo_depth = probe
|
|
110
|
+
else:
|
|
111
|
+
return Limit(method, threshold, deep, first, (shallow, deep), beyond_search=True)
|
|
112
|
+
|
|
113
|
+
lo = lo_depth
|
|
114
|
+
for _ in range(refine):
|
|
115
|
+
mid = (lo * hi) ** 0.5
|
|
116
|
+
value, ok, _ = snr(mid)
|
|
117
|
+
if ok and value >= threshold:
|
|
118
|
+
lo = mid
|
|
119
|
+
else:
|
|
120
|
+
hi = mid
|
|
121
|
+
return Limit(method, threshold, (lo * hi) ** 0.5, first, (shallow, deep))
|
|
@@ -13,14 +13,16 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import datetime as _dt
|
|
15
15
|
import json
|
|
16
|
+
import re
|
|
16
17
|
from dataclasses import asdict, dataclass, field, fields, replace
|
|
17
18
|
|
|
18
19
|
import numpy as np
|
|
19
20
|
|
|
20
|
-
from . import __version__, detect, gpr, gravity, igrf, magnetics, rocks
|
|
21
|
+
from . import __version__, detect, ert, gpr, gravity, igrf, magnetics, rocks
|
|
21
22
|
from .constants import SI_TO_UGAL
|
|
22
23
|
|
|
23
|
-
METHODS = ("gravity", "magnetic", "gradient", "gpr") # gradient: magnetic vertical gradient; gpr: ground-penetrating
|
|
24
|
+
METHODS = ("gravity", "magnetic", "gradient", "gpr", "ert") # gradient: magnetic vertical gradient; gpr: ground-penetrating
|
|
25
|
+
# radar; ert: electrical resistivity tomography (needs pyGIMLi or an ERT service)
|
|
24
26
|
DEFAULT_METHODS = ("gravity", "magnetic")
|
|
25
27
|
|
|
26
28
|
SHAPES: dict[str, str] = {
|
|
@@ -54,6 +56,8 @@ class Target:
|
|
|
54
56
|
conductivity: float | None = None # mS/m, of the target (GPR)
|
|
55
57
|
host_permittivity: float | None = None # relative, of the host (GPR)
|
|
56
58
|
host_conductivity: float | None = None # mS/m, of the host (GPR)
|
|
59
|
+
resistivity: float | None = None # ohm m, of the target (ERT); overrides the material table
|
|
60
|
+
host_resistivity: float | None = None # ohm m, of the host (ERT)
|
|
57
61
|
|
|
58
62
|
def validate(self) -> None:
|
|
59
63
|
if self.shape not in SHAPES:
|
|
@@ -88,6 +92,11 @@ class Survey:
|
|
|
88
92
|
date: str | None = None # ISO date of the survey; default: today
|
|
89
93
|
gpr_frequency: float | None = None # MHz, antenna centre frequency; default: from the target depth
|
|
90
94
|
gpr_system_performance: float = gpr.DEFAULT_SYSTEM_PERFORMANCE_DB # dB, largest two-way loss the system records
|
|
95
|
+
ert_array: str = ert.DEFAULT_ARRAY # dipole-dipole, wenner, schlumberger or gradient
|
|
96
|
+
ert_electrodes: int = ert.DEFAULT_ELECTRODES
|
|
97
|
+
ert_spacing: float | None = None # m between electrodes; default: spread about six times the target depth
|
|
98
|
+
ert_noise: float = ert.DEFAULT_NOISE_PERCENT # % of each apparent resistivity
|
|
99
|
+
ert_invert: bool = True # also invert noisy synthetic data to see whether the target is recovered
|
|
91
100
|
field_total: float | None = None # nT; main field given directly instead of IGRF (all three together)
|
|
92
101
|
field_inclination: float | None = None
|
|
93
102
|
field_declination: float | None = None
|
|
@@ -157,10 +166,17 @@ class Report:
|
|
|
157
166
|
if target.get("shape") != "dyke":
|
|
158
167
|
target.pop("dip", None)
|
|
159
168
|
survey = {k: v for k, v in self.survey.items() if v is not None}
|
|
169
|
+
for prefix, method in (("gpr_", "gpr"), ("ert_", "ert")): # settings of methods not asked for add nothing
|
|
170
|
+
if method not in self.methods:
|
|
171
|
+
survey = {k: v for k, v in survey.items() if not k.startswith(prefix)}
|
|
172
|
+
|
|
173
|
+
def literal(d): # JSON text, but Python booleans
|
|
174
|
+
return re.sub(r"(?<=: )(true|false)(?=,?\n)", lambda m: m.group(1).capitalize(),
|
|
175
|
+
json.dumps(d, indent=4, ensure_ascii=False))
|
|
160
176
|
return SCRIPT.format(
|
|
161
177
|
version=self.version,
|
|
162
|
-
target=
|
|
163
|
-
survey=
|
|
178
|
+
target=literal(target),
|
|
179
|
+
survey=literal(survey),
|
|
164
180
|
methods=json.dumps(self.methods),
|
|
165
181
|
)
|
|
166
182
|
|
|
@@ -379,6 +395,28 @@ def _gpr(t: Target, s: Survey, x) -> MethodResult:
|
|
|
379
395
|
assessment=a_, details=details)
|
|
380
396
|
|
|
381
397
|
|
|
398
|
+
def _ert(t: Target, s: Survey) -> MethodResult:
|
|
399
|
+
"""Electrical resistivity tomography: locally with pyGIMLi, else through DETECTABILITY_ERT_URL, else not available."""
|
|
400
|
+
import os
|
|
401
|
+
try:
|
|
402
|
+
if ert.available():
|
|
403
|
+
d = ert.run(t, s)
|
|
404
|
+
elif os.environ.get("DETECTABILITY_ERT_URL"):
|
|
405
|
+
try:
|
|
406
|
+
d = ert.remote(asdict(t), asdict(s), os.environ["DETECTABILITY_ERT_URL"])
|
|
407
|
+
except Exception as e: # the service is down or too slow: say so instead of failing the whole report
|
|
408
|
+
return MethodResult("ert", False, f"the ERT service did not answer ({type(e).__name__}); try again later")
|
|
409
|
+
if not d.get("available", False):
|
|
410
|
+
return MethodResult("ert", False, d.get("reason", "the ERT service could not compute this target"))
|
|
411
|
+
else:
|
|
412
|
+
return MethodResult("ert", False, 'needs pyGIMLi: pip install "detectability[ert]"')
|
|
413
|
+
except KeyError as e:
|
|
414
|
+
return MethodResult("ert", False, str(e.args[0]) + " (give resistivity and host_resistivity directly)")
|
|
415
|
+
a = d.pop("assessment")
|
|
416
|
+
d["assessment"] = detect.Assessment(**a)
|
|
417
|
+
return MethodResult(**d)
|
|
418
|
+
|
|
419
|
+
|
|
382
420
|
def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
|
|
383
421
|
"""Model ``target`` (a ``Target`` or dict) for each method and assess it for ``survey`` (a ``Survey`` or dict)."""
|
|
384
422
|
t, s = _as(Target, target), _as(Survey, survey)
|
|
@@ -396,6 +434,9 @@ def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
|
|
|
396
434
|
if method == "gpr":
|
|
397
435
|
results.append(_gpr(t, s, x))
|
|
398
436
|
continue
|
|
437
|
+
if method == "ert":
|
|
438
|
+
results.append(_ert(t, s))
|
|
439
|
+
continue
|
|
399
440
|
contrast, units, problem = _contrast(t, method)
|
|
400
441
|
if contrast is None:
|
|
401
442
|
results.append(MethodResult(method, False, problem))
|
|
@@ -364,11 +364,42 @@ def cases() -> list[Case]:
|
|
|
364
364
|
"Ten materials, from dry sand to fresh water", "m/ns", 6.0,
|
|
365
365
|
float(np.max(np.abs(model_v - ref_v) / ref_v * 100.0))))
|
|
366
366
|
|
|
367
|
+
# --- ERT (finite elements, values stored by tools/ert_reference.py) ---------------------------------------------
|
|
368
|
+
out.extend(_ert_cases())
|
|
369
|
+
|
|
367
370
|
# --- independent code ---------------------------------------------------------------------------------------
|
|
368
371
|
out.extend(_harmonica_cases())
|
|
369
372
|
return out
|
|
370
373
|
|
|
371
374
|
|
|
375
|
+
ERT_FILE = "ert_reference.json"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def ert_reference() -> dict | None:
|
|
379
|
+
"""pyGIMLi forward-model values against analytic solutions, stored so the website needs no pyGIMLi."""
|
|
380
|
+
try:
|
|
381
|
+
return json.loads(resources.files("detectability").joinpath("data", ERT_FILE).read_text())
|
|
382
|
+
except FileNotFoundError:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _ert_cases() -> list[Case]:
|
|
387
|
+
ref = ert_reference()
|
|
388
|
+
if not ref:
|
|
389
|
+
return []
|
|
390
|
+
w = ref["wenner_two_layer"]
|
|
391
|
+
v = ref["pygimli"]
|
|
392
|
+
return [
|
|
393
|
+
Case("ert-halfspace", "Analytical limit", "ERT, homogeneous ground", f"pyGIMLi {v} finite elements",
|
|
394
|
+
"Apparent resistivity equals the true resistivity", "100 Ωm, 48 electrodes, dipole-dipole, 1,035 data", "",
|
|
395
|
+
1.0, ref["halfspace_error_percent"]),
|
|
396
|
+
_profile_case("ert-wenner-two-layer", "Analytical limit", "ERT, two layers, Wenner sounding",
|
|
397
|
+
f"pyGIMLi {v} finite elements", "Image-series solution for two layers",
|
|
398
|
+
f"{w['rho1']:g} Ωm over {w['rho2']:g} Ωm, interface at {w['h']:g} m, a = 1–20 m", "Ωm", 2.0,
|
|
399
|
+
np.array(w["spacing"]), np.array(w["model"]), np.array(w["analytic"])),
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
372
403
|
def harmonica_reference() -> dict | None:
|
|
373
404
|
"""The stored Harmonica values and the versions they were computed with (None if the file is missing)."""
|
|
374
405
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detectability
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets.
|
|
5
5
|
Author: Mükremin Nergiz
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -30,6 +30,8 @@ Provides-Extra: test
|
|
|
30
30
|
Requires-Dist: pytest>=7; extra == "test"
|
|
31
31
|
Provides-Extra: validation
|
|
32
32
|
Requires-Dist: harmonica==0.7.0; extra == "validation"
|
|
33
|
+
Provides-Extra: ert
|
|
34
|
+
Requires-Dist: pygimli>=1.5; extra == "ert"
|
|
33
35
|
Dynamic: license-file
|
|
34
36
|
|
|
35
37
|
# Detectability
|
|
@@ -69,7 +71,7 @@ print(report.summary())
|
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
```text
|
|
72
|
-
Detectability 0.
|
|
74
|
+
Detectability 0.6.0: sphere in limestone
|
|
73
75
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
74
76
|
stations every 5 m or less, profile at least 153 m long
|
|
75
77
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -79,6 +81,29 @@ Recommended method: gravity
|
|
|
79
81
|
`report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
|
|
80
82
|
including the modelled profiles and the assumptions.
|
|
81
83
|
|
|
84
|
+
### How deep before a method loses it?
|
|
85
|
+
|
|
86
|
+
`depth_limit()` asks the inverse question — the one a survey plan turns on. Leave `depth_top` out: it is what the
|
|
87
|
+
search solves for.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from detectability import depth_limit
|
|
91
|
+
|
|
92
|
+
for method in ("gravity", "magnetic", "gpr"):
|
|
93
|
+
print(depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
|
|
94
|
+
{"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method=method))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
gravity: detectable (S/N >= 5) down to 25.7 m
|
|
99
|
+
magnetic: never reaches S/N 5; S/N 2.88 even at 0.5 m
|
|
100
|
+
gpr: detectable (S/N >= 5) down to 25.2 m
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Pass `threshold=detect.MARGINAL` for the lower threshold, and `search=(shallow, deep)` to bound the sweep.
|
|
104
|
+
Sweep the target size and you have a detection-limit chart; the website draws one at
|
|
105
|
+
[/limits](https://detectability.mukreminnergiz.com/limits).
|
|
106
|
+
|
|
82
107
|
Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
|
|
83
108
|
geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
|
|
84
109
|
in, and measured contrasts can be given instead.
|
|
@@ -87,10 +112,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
|
|
|
87
112
|
(lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
|
|
88
113
|
FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
89
114
|
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
90
|
-
recording window
|
|
115
|
+
recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
|
|
116
|
+
under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
|
|
117
|
+
apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
|
|
118
|
+
[pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
|
|
91
119
|
|
|
92
120
|
```python
|
|
93
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
121
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
|
|
94
122
|
```
|
|
95
123
|
|
|
96
124
|
## Modules
|
|
@@ -101,6 +129,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
|
101
129
|
| `gravity` | sphere, horizontal cylinder, right rectangular prism (Nagy; Plouff) and its gradient tensor, vertical cylinder (exact, by a rim integral) and its gradient tensor, vertical step, dyke, 2D polygon (Talwani; Won & Bevis) |
|
|
102
130
|
| `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation (a gradiometer reading is the difference at two sensor heights) |
|
|
103
131
|
| `gpr` | ground-penetrating radar: exact velocity and attenuation of a lossy medium, normal-incidence reflection coefficient, target-size factor (geometric optics, Rayleigh regime, Fresnel zone), echo against system performance; typical permittivity and conductivity (Davis & Annan 1989) |
|
|
132
|
+
| `ert` | electrical resistivity tomography with pyGIMLi (optional): electrode layout, 2D finite-element forward model, inversion and recovery check; typical resistivities (Loke 1999, after Keller & Frischknecht 1966) |
|
|
104
133
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
105
134
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
106
135
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -145,6 +174,8 @@ python tools/harmonica_reference.py --check
|
|
|
145
174
|
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
146
175
|
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
147
176
|
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
177
|
+
| Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
|
|
178
|
+
| Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
|
|
148
179
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
149
180
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
150
181
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -172,7 +203,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
172
203
|
|
|
173
204
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
174
205
|
|
|
175
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
206
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
|
|
176
207
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
177
208
|
|
|
178
209
|
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
@@ -4,9 +4,11 @@ pyproject.toml
|
|
|
4
4
|
src/detectability/__init__.py
|
|
5
5
|
src/detectability/constants.py
|
|
6
6
|
src/detectability/detect.py
|
|
7
|
+
src/detectability/ert.py
|
|
7
8
|
src/detectability/gpr.py
|
|
8
9
|
src/detectability/gravity.py
|
|
9
10
|
src/detectability/igrf.py
|
|
11
|
+
src/detectability/limits.py
|
|
10
12
|
src/detectability/magnetics.py
|
|
11
13
|
src/detectability/rocks.py
|
|
12
14
|
src/detectability/survey.py
|
|
@@ -16,12 +18,16 @@ src/detectability.egg-info/SOURCES.txt
|
|
|
16
18
|
src/detectability.egg-info/dependency_links.txt
|
|
17
19
|
src/detectability.egg-info/requires.txt
|
|
18
20
|
src/detectability.egg-info/top_level.txt
|
|
21
|
+
src/detectability/data/ert_reference.json
|
|
19
22
|
src/detectability/data/harmonica_reference.json
|
|
20
23
|
src/detectability/data/igrf14coeffs.txt
|
|
21
24
|
tests/test_detect.py
|
|
25
|
+
tests/test_ert.py
|
|
26
|
+
tests/test_ert_pygimli.py
|
|
22
27
|
tests/test_gpr.py
|
|
23
28
|
tests/test_gravity.py
|
|
24
29
|
tests/test_igrf.py
|
|
30
|
+
tests/test_limits.py
|
|
25
31
|
tests/test_magnetics.py
|
|
26
32
|
tests/test_survey.py
|
|
27
33
|
tests/test_validation.py
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from detectability import ert, evaluate, validation
|
|
7
|
+
from detectability.survey import Target
|
|
8
|
+
|
|
9
|
+
TUNNEL = {"shape": "horizontal_cylinder", "depth_top": 3, "size": 2, "material": "air", "host": "limestone"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_typical_resistivity_is_inside_the_published_range():
|
|
13
|
+
for name, (typ, lo, hi) in ert.RESISTIVITY.items():
|
|
14
|
+
assert lo <= typ <= hi, name
|
|
15
|
+
assert ert.resistivity("limestone") == pytest.approx(np.sqrt(50 * 400), rel=0.01)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_geometry_outlines():
|
|
19
|
+
g = ert.geometry(Target(**TUNNEL))
|
|
20
|
+
assert g.depth_needed == 5 and g.area == pytest.approx(np.pi)
|
|
21
|
+
d = ert.geometry(Target(shape="dyke", depth_top=1, depth_bottom=11, size=2, dip=45))
|
|
22
|
+
assert d.polygon[2][0] == pytest.approx(1 + 10) # bottom shifted by the dip
|
|
23
|
+
assert ert.default_spacing(5, 48) == 0.5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_unavailable_without_pygimli_or_service(monkeypatch):
|
|
27
|
+
monkeypatch.setattr(ert, "available", lambda: False)
|
|
28
|
+
monkeypatch.delenv("DETECTABILITY_ERT_URL", raising=False)
|
|
29
|
+
r = evaluate(TUNNEL, {}, methods=["ert"]).result("ert")
|
|
30
|
+
assert not r.available and "pygimli" in r.reason.lower()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_remote_service_result_and_failure(monkeypatch):
|
|
34
|
+
monkeypatch.setattr(ert, "available", lambda: False)
|
|
35
|
+
monkeypatch.setenv("DETECTABILITY_ERT_URL", "http://127.0.0.1:9/evaluate")
|
|
36
|
+
fake = {"method": "ert", "available": True, "contrast": 7000.0, "contrast_units": "resistivity ratio",
|
|
37
|
+
"instrument": "48 electrodes, dipole-dipole", "instrument_noise": 3.0, "field_noise": None,
|
|
38
|
+
"x": [-1.0, 0.0, 1.0], "anomaly": [1.0, 10.0, 1.0], "details": {"recovered": True},
|
|
39
|
+
"assessment": {"method": "ert", "peak": 10.0, "peak_at": 0.0, "width": 1.0, "shape": "bell", "noise": 3.0,
|
|
40
|
+
"snr": 3.3, "verdict": "marginal", "station_spacing": 0.5, "profile_length": 23.5,
|
|
41
|
+
"assumptions": ["x"], "units": "%"}}
|
|
42
|
+
monkeypatch.setattr(ert, "remote", lambda t, s, url: json.loads(json.dumps(fake)))
|
|
43
|
+
rep = evaluate(TUNNEL, {}, methods=["ert"])
|
|
44
|
+
assert rep.result("ert").assessment.verdict == "marginal" and rep.recommended == "ert"
|
|
45
|
+
|
|
46
|
+
def down(t, s, url):
|
|
47
|
+
raise TimeoutError
|
|
48
|
+
monkeypatch.setattr(ert, "remote", down)
|
|
49
|
+
r = evaluate(TUNNEL, {}, methods=["ert"]).result("ert")
|
|
50
|
+
assert not r.available and "did not answer" in r.reason
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_script_has_python_booleans_and_only_asked_settings():
|
|
54
|
+
rep = evaluate(TUNNEL, {"ert_invert": False}, methods=["gravity"])
|
|
55
|
+
s = rep.script()
|
|
56
|
+
assert "ert_" not in s and "gpr_" not in s and "true" not in s
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_stored_validation_rows_pass():
|
|
60
|
+
rows = [c for c in validation.cases() if c.key.startswith("ert-")]
|
|
61
|
+
assert len(rows) == 2 and all(c.passed for c in rows)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from detectability import evaluate
|
|
4
|
+
|
|
5
|
+
TUNNEL = {"shape": "horizontal_cylinder", "depth_top": 3, "size": 2, "material": "air", "host": "limestone"}
|
|
6
|
+
|
|
7
|
+
pg = pytest.importorskip("pygimli")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_ert_with_pygimli_tunnel_in_limestone():
|
|
11
|
+
r = evaluate(TUNNEL, {"ert_invert": False}, methods=["ert"]).result("ert")
|
|
12
|
+
assert r.available and 5 < r.details["max_anomaly_percent"] < 20
|
|
13
|
+
assert r.assessment.station_spacing == 0.5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_conductive_dyke_is_clear():
|
|
17
|
+
r = evaluate({"shape": "dyke", "depth_top": 1, "depth_bottom": 10, "size": 2, "material": "clay",
|
|
18
|
+
"host": "granite"}, {"ert_invert": False}, methods=["ert"]).result("ert")
|
|
19
|
+
assert r.assessment.verdict == "clearly detectable"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The depth limit must agree with the assessment it inverts: detectable just above it, not just below."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from detectability import Target, depth_limit, detect, evaluate
|
|
6
|
+
|
|
7
|
+
SURVEY = {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}
|
|
8
|
+
CAVITY = {"shape": "sphere", "size": 20, "material": "air", "host": "limestone"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def snr_at(target, depth, method):
|
|
12
|
+
return evaluate({**target, "depth_top": depth}, SURVEY, methods=[method]).results[0].assessment.snr
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize("method", ["gravity", "gpr"])
|
|
16
|
+
def test_limit_brackets_the_threshold(method):
|
|
17
|
+
limit = depth_limit(CAVITY, SURVEY, method=method)
|
|
18
|
+
assert limit.available and limit.depth is not None and not limit.beyond_search
|
|
19
|
+
assert snr_at(CAVITY, limit.depth * 0.99, method) >= detect.CLEAR
|
|
20
|
+
assert snr_at(CAVITY, limit.depth * 1.01, method) < detect.CLEAR
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_marginal_reaches_deeper_than_clear():
|
|
24
|
+
clear = depth_limit(CAVITY, SURVEY, method="gravity")
|
|
25
|
+
marginal = depth_limit(CAVITY, SURVEY, method="gravity", threshold=detect.MARGINAL)
|
|
26
|
+
assert marginal.depth > clear.depth
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_a_bigger_body_is_seen_deeper():
|
|
30
|
+
small = depth_limit({**CAVITY, "size": 10}, SURVEY, method="gravity")
|
|
31
|
+
large = depth_limit({**CAVITY, "size": 40}, SURVEY, method="gravity")
|
|
32
|
+
assert large.depth > small.depth
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_depth_top_is_ignored_and_optional():
|
|
36
|
+
without = depth_limit(CAVITY, SURVEY, method="gravity")
|
|
37
|
+
with_depth = depth_limit({**CAVITY, "depth_top": 999}, SURVEY, method="gravity")
|
|
38
|
+
as_object = depth_limit(Target(shape="sphere", depth_top=1, size=20, material="air", host="limestone"),
|
|
39
|
+
SURVEY, method="gravity")
|
|
40
|
+
assert without.depth == pytest.approx(with_depth.depth, rel=1e-9)
|
|
41
|
+
assert without.depth == pytest.approx(as_object.depth, rel=1e-9)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_never_detected_reports_the_shallowest_signal():
|
|
45
|
+
limit = depth_limit(CAVITY, SURVEY, method="magnetic")
|
|
46
|
+
assert limit.depth is None and limit.available
|
|
47
|
+
assert 0 < limit.snr_shallowest < detect.CLEAR
|
|
48
|
+
assert "never reaches" in str(limit)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_still_detected_at_the_bottom_of_the_search():
|
|
52
|
+
limit = depth_limit(CAVITY, SURVEY, method="gravity", search=(0.5, 5.0))
|
|
53
|
+
assert limit.beyond_search and limit.depth == 5.0
|
|
54
|
+
assert "at least" in str(limit)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_unassessable_method_says_why():
|
|
58
|
+
limit = depth_limit({"shape": "sphere", "size": 20, "material": "magnetite", "host": "granite"},
|
|
59
|
+
SURVEY, method="gravity")
|
|
60
|
+
assert not limit.available and limit.depth is None
|
|
61
|
+
assert "no density for 'magnetite'" in limit.reason and "not assessed" in str(limit)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_body_keeps_its_thickness_while_it_moves_down():
|
|
65
|
+
dyke = {"shape": "dyke", "size": 10, "depth_top": 5, "depth_bottom": 45,
|
|
66
|
+
"material": "air", "host": "limestone"}
|
|
67
|
+
limit = depth_limit(dyke, SURVEY, method="gravity")
|
|
68
|
+
assert limit.depth is not None
|
|
69
|
+
# The 40 m thickness must be kept at the limit, or the answer would be about a different body.
|
|
70
|
+
assert snr_at({**dyke, "depth_bottom": limit.depth * 0.99 + 40}, limit.depth * 0.99, "gravity") >= detect.CLEAR
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_a_narrow_search_still_lands_on_the_same_answer():
|
|
74
|
+
wide = depth_limit(CAVITY, SURVEY, method="gravity")
|
|
75
|
+
narrow = depth_limit(CAVITY, SURVEY, method="gravity", search=(20.0, 30.0), steps=6)
|
|
76
|
+
assert narrow.depth == pytest.approx(wide.depth, rel=1e-3)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("search", [(0, 100), (-1, 10), (100, 10), (10, 10)])
|
|
80
|
+
def test_a_bad_search_range_is_refused(search):
|
|
81
|
+
with pytest.raises(ValueError, match="search must be"):
|
|
82
|
+
depth_limit(CAVITY, SURVEY, method="gravity", search=search)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_too_few_steps_is_refused():
|
|
86
|
+
with pytest.raises(ValueError, match="steps must be"):
|
|
87
|
+
depth_limit(CAVITY, SURVEY, method="gravity", steps=1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_an_invalid_target_is_refused():
|
|
91
|
+
with pytest.raises(ValueError):
|
|
92
|
+
depth_limit({"shape": "sphere", "size": -5, "material": "air", "host": "limestone"},
|
|
93
|
+
SURVEY, method="gravity")
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
"""Detectability: pre-survey detectability, modelled and reproducible."""
|
|
2
|
-
|
|
3
|
-
__version__ = "0.4.0"
|
|
4
|
-
|
|
5
|
-
from .survey import DEFAULT_METHODS, METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
|
|
6
|
-
|
|
7
|
-
__all__ = ["evaluate", "Target", "Survey", "Report", "SHAPES", "METHODS", "DEFAULT_METHODS", "__version__"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|