detectability 0.3.0__tar.gz → 0.5.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.3.0/src/detectability.egg-info → detectability-0.5.0}/PKG-INFO +22 -6
- {detectability-0.3.0 → detectability-0.5.0}/README.md +19 -5
- {detectability-0.3.0 → detectability-0.5.0}/pyproject.toml +3 -1
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/__init__.py +1 -1
- detectability-0.5.0/src/detectability/data/ert_reference.json +40 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/detect.py +1 -1
- detectability-0.5.0/src/detectability/ert.py +272 -0
- detectability-0.5.0/src/detectability/gpr.py +176 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/survey.py +156 -6
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/validation.py +66 -1
- {detectability-0.3.0 → detectability-0.5.0/src/detectability.egg-info}/PKG-INFO +22 -6
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability.egg-info/SOURCES.txt +6 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability.egg-info/requires.txt +3 -0
- detectability-0.5.0/tests/test_ert.py +61 -0
- detectability-0.5.0/tests/test_ert_pygimli.py +19 -0
- detectability-0.5.0/tests/test_gpr.py +102 -0
- {detectability-0.3.0 → detectability-0.5.0}/LICENSE +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/setup.cfg +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/constants.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/data/harmonica_reference.json +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/data/igrf14coeffs.txt +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/gravity.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/igrf.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/magnetics.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability/rocks.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability.egg-info/dependency_links.txt +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/src/detectability.egg-info/top_level.txt +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/tests/test_detect.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/tests/test_gravity.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/tests/test_igrf.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/tests/test_magnetics.py +0 -0
- {detectability-0.3.0 → detectability-0.5.0}/tests/test_survey.py +0 -0
- {detectability-0.3.0 → detectability-0.5.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.5.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,13 +30,15 @@ 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
|
|
36
38
|
|
|
39
|
+
[](https://doi.org/10.5281/zenodo.23931815)
|
|
37
40
|
[](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
|
|
38
41
|
[](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
|
|
39
|
-
[](https://doi.org/10.5281/zenodo.23931815)
|
|
40
42
|
|
|
41
43
|
**Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
|
|
42
44
|
magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
|
|
@@ -69,7 +71,7 @@ print(report.summary())
|
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
```text
|
|
72
|
-
Detectability 0.
|
|
74
|
+
Detectability 0.5.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
|
|
@@ -85,10 +87,15 @@ in, and measured contrasts can be given instead.
|
|
|
85
87
|
|
|
86
88
|
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
87
89
|
(lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
|
|
88
|
-
FEREX 4.034 and GEM GSM-19G
|
|
90
|
+
FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
91
|
+
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
92
|
+
recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
|
|
93
|
+
under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
|
|
94
|
+
apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
|
|
95
|
+
[pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
|
|
89
96
|
|
|
90
97
|
```python
|
|
91
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
98
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
|
|
92
99
|
```
|
|
93
100
|
|
|
94
101
|
## Modules
|
|
@@ -98,6 +105,8 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
|
98
105
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
99
106
|
| `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) |
|
|
100
107
|
| `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) |
|
|
108
|
+
| `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) |
|
|
109
|
+
| `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) |
|
|
101
110
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
102
111
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
103
112
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -137,6 +146,13 @@ python tools/harmonica_reference.py --check
|
|
|
137
146
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
138
147
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
139
148
|
| Poisson's relation | Vertical contact, gravity ↔ magnetic | Vertical derivative of the gravity step | 3.9e-06 % | 1.0e-04 % |
|
|
149
|
+
| Analytical limit | GPR, attenuation in a low-loss host | Low-loss limit 1636 σ/√εr dB/m (σ in S/m) | 1.1e-04 % | 0.01 % |
|
|
150
|
+
| Analytical limit | GPR, velocity in a low-loss host | c/√εr | 2.5e-06 % | 1.0e-04 % |
|
|
151
|
+
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
152
|
+
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
153
|
+
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
154
|
+
| Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
|
|
155
|
+
| Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
|
|
140
156
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
141
157
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
142
158
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -164,7 +180,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
164
180
|
|
|
165
181
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
166
182
|
|
|
167
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
183
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
|
|
168
184
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
169
185
|
|
|
170
186
|
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Detectability
|
|
2
2
|
|
|
3
|
+
[](https://doi.org/10.5281/zenodo.23931815)
|
|
3
4
|
[](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
|
|
4
5
|
[](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
|
|
5
|
-
[](https://doi.org/10.5281/zenodo.23931815)
|
|
6
6
|
|
|
7
7
|
**Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
|
|
8
8
|
magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
|
|
@@ -35,7 +35,7 @@ print(report.summary())
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
```text
|
|
38
|
-
Detectability 0.
|
|
38
|
+
Detectability 0.5.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
|
|
@@ -51,10 +51,15 @@ in, and measured contrasts can be given instead.
|
|
|
51
51
|
|
|
52
52
|
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
53
53
|
(lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
|
|
54
|
-
FEREX 4.034 and GEM GSM-19G
|
|
54
|
+
FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
55
|
+
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
56
|
+
recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
|
|
57
|
+
under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
|
|
58
|
+
apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
|
|
59
|
+
[pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
|
|
55
60
|
|
|
56
61
|
```python
|
|
57
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
62
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
|
|
58
63
|
```
|
|
59
64
|
|
|
60
65
|
## Modules
|
|
@@ -64,6 +69,8 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
|
64
69
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
65
70
|
| `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) |
|
|
66
71
|
| `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) |
|
|
72
|
+
| `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) |
|
|
73
|
+
| `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) |
|
|
67
74
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
68
75
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
69
76
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -103,6 +110,13 @@ python tools/harmonica_reference.py --check
|
|
|
103
110
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
104
111
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
105
112
|
| Poisson's relation | Vertical contact, gravity ↔ magnetic | Vertical derivative of the gravity step | 3.9e-06 % | 1.0e-04 % |
|
|
113
|
+
| Analytical limit | GPR, attenuation in a low-loss host | Low-loss limit 1636 σ/√εr dB/m (σ in S/m) | 1.1e-04 % | 0.01 % |
|
|
114
|
+
| Analytical limit | GPR, velocity in a low-loss host | c/√εr | 2.5e-06 % | 1.0e-04 % |
|
|
115
|
+
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
116
|
+
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
117
|
+
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
118
|
+
| Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
|
|
119
|
+
| Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
|
|
106
120
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
107
121
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
108
122
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -130,7 +144,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
130
144
|
|
|
131
145
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
132
146
|
|
|
133
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
147
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
|
|
134
148
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
135
149
|
|
|
136
150
|
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.5.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,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"}
|
|
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())
|