detectability 0.2.1__tar.gz → 0.4.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.2.1/src/detectability.egg-info → detectability-0.4.0}/PKG-INFO +28 -8
- {detectability-0.2.1 → detectability-0.4.0}/README.md +26 -7
- {detectability-0.2.1 → detectability-0.4.0}/pyproject.toml +2 -1
- detectability-0.4.0/src/detectability/__init__.py +7 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/detect.py +26 -6
- detectability-0.4.0/src/detectability/gpr.py +176 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/survey.py +159 -15
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/validation.py +53 -1
- {detectability-0.2.1 → detectability-0.4.0/src/detectability.egg-info}/PKG-INFO +28 -8
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/SOURCES.txt +2 -0
- {detectability-0.2.1 → detectability-0.4.0}/tests/test_detect.py +11 -2
- detectability-0.4.0/tests/test_gpr.py +102 -0
- {detectability-0.2.1 → detectability-0.4.0}/tests/test_survey.py +40 -0
- detectability-0.2.1/src/detectability/__init__.py +0 -7
- {detectability-0.2.1 → detectability-0.4.0}/LICENSE +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/setup.cfg +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/constants.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/data/harmonica_reference.json +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/data/igrf14coeffs.txt +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/gravity.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/igrf.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/magnetics.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability/rocks.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/dependency_links.txt +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/requires.txt +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/top_level.txt +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/tests/test_gravity.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/tests/test_igrf.py +0 -0
- {detectability-0.2.1 → detectability-0.4.0}/tests/test_magnetics.py +0 -0
- {detectability-0.2.1 → detectability-0.4.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.4.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
|
|
@@ -9,6 +9,7 @@ Project-URL: Documentation, https://detectability.mukreminnergiz.com/method
|
|
|
9
9
|
Project-URL: Validation, https://detectability.mukreminnergiz.com/validation
|
|
10
10
|
Project-URL: Repository, https://github.com/mukremin-nergiz/detectability
|
|
11
11
|
Project-URL: Issues, https://github.com/mukremin-nergiz/detectability/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md
|
|
12
13
|
Project-URL: Citation, https://doi.org/10.5281/zenodo.23931815
|
|
13
14
|
Keywords: geophysics,gravity,magnetics,survey design,near-surface,forward modelling,IGRF
|
|
14
15
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -33,9 +34,9 @@ Dynamic: license-file
|
|
|
33
34
|
|
|
34
35
|
# Detectability
|
|
35
36
|
|
|
37
|
+
[](https://doi.org/10.5281/zenodo.23931815)
|
|
36
38
|
[](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
|
|
37
39
|
[](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
|
|
38
|
-
[](https://doi.org/10.5281/zenodo.23931815)
|
|
39
40
|
|
|
40
41
|
**Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
|
|
41
42
|
magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
|
|
@@ -68,7 +69,7 @@ print(report.summary())
|
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
```text
|
|
71
|
-
Detectability 0.
|
|
72
|
+
Detectability 0.4.0: sphere in limestone
|
|
72
73
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
73
74
|
stations every 5 m or less, profile at least 153 m long
|
|
74
75
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -82,16 +83,27 @@ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `d
|
|
|
82
83
|
geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
|
|
83
84
|
in, and measured contrasts can be given instead.
|
|
84
85
|
|
|
86
|
+
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
87
|
+
(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; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
89
|
+
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
90
|
+
recording window. The defaults are `gravity` and `magnetic`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
94
|
+
```
|
|
95
|
+
|
|
85
96
|
## Modules
|
|
86
97
|
|
|
87
98
|
| Module | Contents |
|
|
88
99
|
|---|---|
|
|
89
100
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
90
101
|
| `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) |
|
|
91
|
-
| `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation |
|
|
102
|
+
| `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
|
+
| `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) |
|
|
92
104
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
93
105
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
94
|
-
| `detect` | instrument
|
|
106
|
+
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
95
107
|
|
|
96
108
|
## Validation
|
|
97
109
|
|
|
@@ -117,6 +129,7 @@ python tools/harmonica_reference.py --check
|
|
|
117
129
|
| Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
|
|
118
130
|
| Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
|
|
119
131
|
| Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
|
|
132
|
+
| Independent formula | Sphere, magnetic gradient (gradiometer reading) | Closed-form vertical-dipole field at both sensors | 7.8e-14 % | 1.0e-10 % |
|
|
120
133
|
| Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
|
|
121
134
|
| Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
|
|
122
135
|
| Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
|
|
@@ -127,6 +140,11 @@ python tools/harmonica_reference.py --check
|
|
|
127
140
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
128
141
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
129
142
|
| Poisson's relation | Vertical contact, gravity ↔ magnetic | Vertical derivative of the gravity step | 3.9e-06 % | 1.0e-04 % |
|
|
143
|
+
| Analytical limit | GPR, attenuation in a low-loss host | Low-loss limit 1636 σ/√εr dB/m (σ in S/m) | 1.1e-04 % | 0.01 % |
|
|
144
|
+
| Analytical limit | GPR, velocity in a low-loss host | c/√εr | 2.5e-06 % | 1.0e-04 % |
|
|
145
|
+
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
146
|
+
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
147
|
+
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
130
148
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
131
149
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
132
150
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -147,16 +165,18 @@ pytest
|
|
|
147
165
|
Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
|
|
148
166
|
crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
|
|
149
167
|
not guarantees. Instrument presets come from manufacturers' data sheets (`detect.INSTRUMENTS` lists each figure and its
|
|
150
|
-
source); field noise, which usually dominates, is a stated assumption that you can change.
|
|
168
|
+
source); field noise, which usually dominates, is a stated assumption that you can change. For gradiometers the default
|
|
169
|
+
field noise (0.5 nT) is an assumption, not a published figure: soil, heading and walking noise depend on the site.
|
|
151
170
|
|
|
152
171
|
## Citation
|
|
153
172
|
|
|
154
173
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
155
174
|
|
|
156
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
175
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
|
|
157
176
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
158
177
|
|
|
159
|
-
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
|
|
178
|
+
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
179
|
+
releases: [CHANGELOG.md](https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md).
|
|
160
180
|
|
|
161
181
|
## Author
|
|
162
182
|
|
|
@@ -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.4.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
|
|
@@ -49,16 +49,27 @@ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `d
|
|
|
49
49
|
geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
|
|
50
50
|
in, and measured contrasts can be given instead.
|
|
51
51
|
|
|
52
|
+
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
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; 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. The defaults are `gravity` and `magnetic`:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
60
|
+
```
|
|
61
|
+
|
|
52
62
|
## Modules
|
|
53
63
|
|
|
54
64
|
| Module | Contents |
|
|
55
65
|
|---|---|
|
|
56
66
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
57
67
|
| `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) |
|
|
58
|
-
| `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation |
|
|
68
|
+
| `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
|
+
| `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) |
|
|
59
70
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
60
71
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
61
|
-
| `detect` | instrument
|
|
72
|
+
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
62
73
|
|
|
63
74
|
## Validation
|
|
64
75
|
|
|
@@ -84,6 +95,7 @@ python tools/harmonica_reference.py --check
|
|
|
84
95
|
| Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
|
|
85
96
|
| Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
|
|
86
97
|
| Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
|
|
98
|
+
| Independent formula | Sphere, magnetic gradient (gradiometer reading) | Closed-form vertical-dipole field at both sensors | 7.8e-14 % | 1.0e-10 % |
|
|
87
99
|
| Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
|
|
88
100
|
| Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
|
|
89
101
|
| Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
|
|
@@ -94,6 +106,11 @@ python tools/harmonica_reference.py --check
|
|
|
94
106
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
95
107
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
96
108
|
| Poisson's relation | Vertical contact, gravity ↔ magnetic | Vertical derivative of the gravity step | 3.9e-06 % | 1.0e-04 % |
|
|
109
|
+
| Analytical limit | GPR, attenuation in a low-loss host | Low-loss limit 1636 σ/√εr dB/m (σ in S/m) | 1.1e-04 % | 0.01 % |
|
|
110
|
+
| Analytical limit | GPR, velocity in a low-loss host | c/√εr | 2.5e-06 % | 1.0e-04 % |
|
|
111
|
+
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
112
|
+
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
113
|
+
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
97
114
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
98
115
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
99
116
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -114,16 +131,18 @@ pytest
|
|
|
114
131
|
Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
|
|
115
132
|
crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
|
|
116
133
|
not guarantees. Instrument presets come from manufacturers' data sheets (`detect.INSTRUMENTS` lists each figure and its
|
|
117
|
-
source); field noise, which usually dominates, is a stated assumption that you can change.
|
|
134
|
+
source); field noise, which usually dominates, is a stated assumption that you can change. For gradiometers the default
|
|
135
|
+
field noise (0.5 nT) is an assumption, not a published figure: soil, heading and walking noise depend on the site.
|
|
118
136
|
|
|
119
137
|
## Citation
|
|
120
138
|
|
|
121
139
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
122
140
|
|
|
123
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
141
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
|
|
124
142
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
125
143
|
|
|
126
|
-
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
|
|
144
|
+
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
145
|
+
releases: [CHANGELOG.md](https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md).
|
|
127
146
|
|
|
128
147
|
## Author
|
|
129
148
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "detectability"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.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"]
|
|
@@ -31,6 +31,7 @@ Documentation = "https://detectability.mukreminnergiz.com/method"
|
|
|
31
31
|
Validation = "https://detectability.mukreminnergiz.com/validation"
|
|
32
32
|
Repository = "https://github.com/mukremin-nergiz/detectability"
|
|
33
33
|
Issues = "https://github.com/mukremin-nergiz/detectability/issues"
|
|
34
|
+
Changelog = "https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md"
|
|
34
35
|
Citation = "https://doi.org/10.5281/zenodo.23931815"
|
|
35
36
|
|
|
36
37
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,7 @@
|
|
|
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__"]
|
|
@@ -19,11 +19,12 @@ STATIONS_ACROSS = 5
|
|
|
19
19
|
class Instrument:
|
|
20
20
|
"""An instrument preset: its noise, what the figure means and where it comes from (checked 4 Nov 2026)."""
|
|
21
21
|
|
|
22
|
-
method: str # "gravity" or "magnetic
|
|
23
|
-
noise: float # one standard deviation of a reading, in µGal (gravity) or nT (magnetic)
|
|
22
|
+
method: str # "gravity", "magnetic" or "gradient" (magnetic vertical gradient)
|
|
23
|
+
noise: float # one standard deviation of a reading, in µGal (gravity) or nT (magnetic and gradient)
|
|
24
24
|
basis: str # what the manufacturer's figure is
|
|
25
25
|
reference: str # instrument the figure is taken from
|
|
26
26
|
source: str # data sheet or publication
|
|
27
|
+
baseline: float | None = None # m, vertical distance between the two sensors (gradiometers)
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
INSTRUMENTS: dict[str, Instrument] = {
|
|
@@ -45,18 +46,37 @@ INSTRUMENTS: dict[str, Instrument] = {
|
|
|
45
46
|
"caesium vapour": Instrument(
|
|
46
47
|
"magnetic", 0.008, "noise < 0.008 nT/√Hz RMS, i.e. at 1 Hz", "Geometrics G-858",
|
|
47
48
|
"https://www.geomatrix.co.uk/land-products/magnetic/g-858/"),
|
|
49
|
+
# Vertical gradiometers: two sensors one above the other; the reading is the lower minus the upper sensor, in nT
|
|
50
|
+
# over the baseline (as Bartington and Foerster report it; nT/m only for a 1 m baseline).
|
|
51
|
+
"Bartington Grad601": Instrument(
|
|
52
|
+
"gradient", 0.1, "noise 100 pT peak-to-peak maximum, taken as one standard deviation to be conservative",
|
|
53
|
+
"Bartington Grad-01-1000L sensor (Grad601)", "https://www.bartingtondownloads.com/wp-content/uploads/DS0060.pdf",
|
|
54
|
+
baseline=1.0),
|
|
55
|
+
"Sensys FGM650": Instrument(
|
|
56
|
+
"gradient", 0.04, "noise < 40 pT rms/√Hz at 1 Hz", "Sensys FGM650 fluxgate gradiometer",
|
|
57
|
+
"https://sensysmagnetometer.com/de/produkte/fgm650-gradiometer/", baseline=0.65),
|
|
58
|
+
"Foerster FEREX 4.034": Instrument(
|
|
59
|
+
"gradient", 0.04, "noise < 40 pT/√Hz at 1 Hz (MG-10 probe)", "Foerster FEREX 4.034 with MG-10-550 probe",
|
|
60
|
+
"https://www.foerstergroup.com/FOERSTER/downloads/product/ferex/FOERSTER_FLY_FEREX4.034_DE.pdf", baseline=0.55),
|
|
61
|
+
"GEM GSM-19G": Instrument(
|
|
62
|
+
"gradient", 0.031, "0.022 nT at 1 Hz per sensor; two sensors in difference give 0.022 × √2",
|
|
63
|
+
"GEM Systems GSM-19G Overhauser gradiometer (one staff section, 0.56 m, per the manual)",
|
|
64
|
+
"https://www.gemsys.ca/wp-content/themes/gemsystems/pdf/GEM_Overhauser_GSM_19_fin.pdf", baseline=0.56),
|
|
48
65
|
}
|
|
49
|
-
# Fluxgate gradiometers (e.g. Bartington Grad601) measure a vertical gradient, not the total-field anomaly modelled
|
|
50
|
-
# here, so they are not offered until a gradiometer response is implemented.
|
|
51
66
|
|
|
52
67
|
GRAVIMETERS_UGAL: dict[str, float] = {k: v.noise for k, v in INSTRUMENTS.items() if v.method == "gravity"}
|
|
53
68
|
MAGNETOMETERS_NT: dict[str, float] = {k: v.noise for k, v in INSTRUMENTS.items() if v.method == "magnetic"}
|
|
69
|
+
GRADIOMETERS_NT: dict[str, float] = {k: v.noise for k, v in INSTRUMENTS.items() if v.method == "gradient"}
|
|
54
70
|
|
|
55
71
|
# Field noise is what remains after corrections and usually dominates the instrument noise.
|
|
56
72
|
DEFAULT_FIELD_NOISE_UGAL = 10.0 # microgravity: tares, drift residuals, terrain, cultural noise
|
|
57
73
|
DEFAULT_FIELD_NOISE_NT = 2.0 # quiet rural site after diurnal correction; urban and industrial sites are far noisier
|
|
74
|
+
# Gradient surveys cancel diurnal variation and the regional field, so their field noise is mainly soil, heading and
|
|
75
|
+
# walking noise. 0.5 nT is an assumption, not a published figure (the Grad601 manual asks for a set-up spot that varies
|
|
76
|
+
# by less than about 0.5 nT over 1 m); change it for the site.
|
|
77
|
+
DEFAULT_FIELD_NOISE_GRADIENT_NT = 0.5
|
|
58
78
|
|
|
59
|
-
UNITS = {"gravity": "µGal", "magnetic": "nT"}
|
|
79
|
+
UNITS = {"gravity": "µGal", "magnetic": "nT", "gradient": "nT", "gpr": "× detection limit"}
|
|
60
80
|
|
|
61
81
|
|
|
62
82
|
def effective_noise(instrument: float, field_noise: float) -> float:
|
|
@@ -143,7 +163,7 @@ def assess(x, anomaly, noise: float, depth: float, method: str = "gravity") -> A
|
|
|
143
163
|
f"Station spacing allows at least {STATIONS_ACROSS} stations across the anomaly width ({width:.3g} m).",
|
|
144
164
|
"Flat topography, homogeneous host and no regional trend; terrain and regional effects reduce the margin.",
|
|
145
165
|
]
|
|
146
|
-
if method
|
|
166
|
+
if method in ("magnetic", "gradient"):
|
|
147
167
|
assumptions.append("Peak is the largest absolute value of the (often two-lobed) anomaly; no remanence unless given.")
|
|
148
168
|
return Assessment(method, peak, float(x[peak_i]), width, shape, noise, snr, verdict, spacing, length, assumptions,
|
|
149
169
|
unit)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Ground-penetrating radar (GPR): wave speed and attenuation in the ground, reflection at the target, and the echo of
|
|
2
|
+
a buried target compared with what the radar system can still detect.
|
|
3
|
+
|
|
4
|
+
The model is a radar range (loss budget) calculation, in the spirit of GPR survey-design practice (Annan & Cosway 1992,
|
|
5
|
+
"Ground penetrating radar survey design", SAGEEP '92, 329-351; Annan 2009, "Electromagnetic principles of ground
|
|
6
|
+
penetrating radar", in Jol (ed.), Ground Penetrating Radar: Theory and Applications, Elsevier):
|
|
7
|
+
|
|
8
|
+
echo amplitude (relative to the amplitude 1 m from the antenna) = |R| * S * exp(-2 alpha r) * 1 m / (2 r)
|
|
9
|
+
|
|
10
|
+
with R the normal-incidence reflection coefficient between host and target, S the target-size factor (1 for a large
|
|
11
|
+
flat reflector), alpha the attenuation of the host and r the range from the antenna to the reflecting surface. The echo is
|
|
12
|
+
detectable while its loss stays within the system performance Q (dB, the largest two-way loss the system can still
|
|
13
|
+
record, referred to 1 m). Signal-to-noise ratio = echo / 10**(-Q/20).
|
|
14
|
+
|
|
15
|
+
Each step and its limits:
|
|
16
|
+
- Wave speed and attenuation are exact for a homogeneous medium with frequency-independent permittivity and
|
|
17
|
+
conductivity. Real soils lose more at high frequency (water relaxation, scattering), so results above ~500 MHz in
|
|
18
|
+
wet or clayey ground are optimistic.
|
|
19
|
+
- R is exact for a plane wave at normal incidence on a plane boundary; a perfect conductor (metal) has R = -1.
|
|
20
|
+
- S is geometric optics for curved targets: a sphere of radius a at range r returns a / (r + a) of a flat reflector's
|
|
21
|
+
echo and a horizontal cylinder sqrt(a / (r + a)) (one curved direction). Where the target is small compared with the
|
|
22
|
+
wavelength (k a < 1, k = 2 pi / lambda) the echo falls in the Rayleigh regime as (k a)**2 (sphere) and (k a)**1.5
|
|
23
|
+
(cylinder), joined continuously at k a = 1. Flat tops smaller than the first Fresnel zone (radius sqrt(lambda r / 2))
|
|
24
|
+
return the fraction of the zone they cover. Antenna gain, beam pattern, coupling and surface clutter are in Q.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
C0 = 299_792_458.0 # speed of light in vacuum, m/s
|
|
34
|
+
EPS0 = 8.8541878128e-12 # vacuum permittivity, F/m (CODATA 2018)
|
|
35
|
+
MU0 = 1.25663706212e-6 # vacuum permeability, H/m (CODATA 2018)
|
|
36
|
+
NEPER_TO_DB = 20.0 / np.log(10.0)
|
|
37
|
+
|
|
38
|
+
# Typical relative permittivity and conductivity (mS/m), each as (typical, low, high). Ranges from the table of
|
|
39
|
+
# Davis & Annan (1989, Geophysical Prospecting 37, 531-551), as reproduced in USACE EM 1110-1-1802 (1995) Table 4-3;
|
|
40
|
+
# "typical" is the middle of the permittivity range and the geometric middle of the conductivity range. The same table
|
|
41
|
+
# gives velocity and attenuation, which the tests check against these values. Verify against the source before release.
|
|
42
|
+
PERMITTIVITY: dict[str, tuple[float, float, float]] = {
|
|
43
|
+
"air": (1.0, 1.0, 1.0),
|
|
44
|
+
"distilled water": (80.0, 80.0, 80.0),
|
|
45
|
+
"fresh water": (80.0, 80.0, 80.0),
|
|
46
|
+
"sea water": (80.0, 80.0, 80.0),
|
|
47
|
+
"dry sand": (4.0, 3.0, 5.0),
|
|
48
|
+
"wet sand": (25.0, 20.0, 30.0),
|
|
49
|
+
"limestone": (6.0, 4.0, 8.0),
|
|
50
|
+
"shale": (10.0, 5.0, 15.0),
|
|
51
|
+
"silt": (17.5, 5.0, 30.0),
|
|
52
|
+
"clay": (22.5, 5.0, 40.0),
|
|
53
|
+
"granite": (5.0, 4.0, 6.0),
|
|
54
|
+
"rock salt": (5.5, 5.0, 6.0),
|
|
55
|
+
"ice": (3.5, 3.0, 4.0),
|
|
56
|
+
}
|
|
57
|
+
CONDUCTIVITY_MS_M: dict[str, tuple[float, float, float]] = {
|
|
58
|
+
"air": (0.0, 0.0, 0.0),
|
|
59
|
+
"distilled water": (0.01, 0.01, 0.01),
|
|
60
|
+
"fresh water": (0.5, 0.5, 0.5),
|
|
61
|
+
"sea water": (3000.0, 3000.0, 3000.0),
|
|
62
|
+
"dry sand": (0.01, 0.01, 0.01),
|
|
63
|
+
"wet sand": (0.3, 0.1, 1.0),
|
|
64
|
+
"limestone": (1.0, 0.5, 2.0),
|
|
65
|
+
"shale": (10.0, 1.0, 100.0),
|
|
66
|
+
"silt": (10.0, 1.0, 100.0),
|
|
67
|
+
"clay": (45.0, 2.0, 1000.0),
|
|
68
|
+
"granite": (0.1, 0.01, 1.0),
|
|
69
|
+
"rock salt": (0.1, 0.01, 1.0),
|
|
70
|
+
"ice": (0.01, 0.01, 0.01),
|
|
71
|
+
}
|
|
72
|
+
METALS = {"metal"} # perfect conductors: R = -1 whatever the host
|
|
73
|
+
|
|
74
|
+
# Antenna centre frequency for a target depth (Annan 2001, as given by Robinson et al. 2013, Table 2 in USEPA CLU-IN
|
|
75
|
+
# "Ground Penetrating Radar"): a rule of thumb that keeps the resolution near a quarter of the depth.
|
|
76
|
+
DEPTH_TO_FREQUENCY_MHZ = ((0.5, 1000.0), (1.0, 500.0), (2.0, 200.0), (5.0, 100.0), (10.0, 50.0), (30.0, 25.0),
|
|
77
|
+
(50.0, 10.0))
|
|
78
|
+
ANTENNAS_MHZ = (10.0, 25.0, 50.0, 100.0, 200.0, 250.0, 400.0, 500.0, 800.0, 1000.0, 1600.0)
|
|
79
|
+
|
|
80
|
+
DEFAULT_SYSTEM_PERFORMANCE_DB = 100.0 # typical dynamic range 90-130 dB (Benson et al. 1984; USACE 1995)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Medium:
|
|
85
|
+
permittivity: float # relative
|
|
86
|
+
conductivity: float # S/m
|
|
87
|
+
|
|
88
|
+
def wavenumber(self, f: float) -> complex:
|
|
89
|
+
"""Complex wavenumber beta - i alpha at frequency f (Hz)."""
|
|
90
|
+
w = 2 * np.pi * f
|
|
91
|
+
eps = self.permittivity * EPS0 - 1j * self.conductivity / w
|
|
92
|
+
k = w * np.sqrt(MU0 * eps + 0j)
|
|
93
|
+
return complex(k.real, -abs(k.imag))
|
|
94
|
+
|
|
95
|
+
def velocity(self, f: float) -> float:
|
|
96
|
+
"""Phase velocity, m/s."""
|
|
97
|
+
return 2 * np.pi * f / self.wavenumber(f).real
|
|
98
|
+
|
|
99
|
+
def attenuation_db(self, f: float) -> float:
|
|
100
|
+
"""Amplitude attenuation, dB per metre (one way)."""
|
|
101
|
+
return -self.wavenumber(f).imag * NEPER_TO_DB
|
|
102
|
+
|
|
103
|
+
def impedance(self, f: float) -> complex:
|
|
104
|
+
w = 2 * np.pi * f
|
|
105
|
+
return np.sqrt(MU0 / (self.permittivity * EPS0 - 1j * self.conductivity / w) + 0j)
|
|
106
|
+
|
|
107
|
+
def wavelength(self, f: float) -> float:
|
|
108
|
+
return self.velocity(f) / f
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def medium(name: str) -> Medium:
|
|
112
|
+
try:
|
|
113
|
+
return Medium(PERMITTIVITY[name][0], CONDUCTIVITY_MS_M[name][0] / 1000.0)
|
|
114
|
+
except KeyError:
|
|
115
|
+
raise KeyError(f"no GPR properties for {name!r}; known: {', '.join(sorted(set(PERMITTIVITY) | METALS))}") from None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def low_loss_attenuation_db(permittivity: float, conductivity: float) -> float:
|
|
119
|
+
"""High-frequency (low-loss) limit of the attenuation, dB/m: 1636 sigma / sqrt(eps_r) with sigma in S/m."""
|
|
120
|
+
return NEPER_TO_DB * conductivity / 2 * np.sqrt(MU0 / EPS0) / np.sqrt(permittivity)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def reflection(host: Medium, target: Medium | None, f: float) -> complex:
|
|
124
|
+
"""Normal-incidence reflection coefficient from ``host`` onto ``target`` (None: perfect conductor, R = -1)."""
|
|
125
|
+
if target is None:
|
|
126
|
+
return complex(-1.0)
|
|
127
|
+
z1, z2 = host.impedance(f), target.impedance(f)
|
|
128
|
+
return complex((z2 - z1) / (z2 + z1))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fresnel_radius(wavelength: float, r: np.ndarray) -> np.ndarray:
|
|
132
|
+
"""Radius of the first Fresnel zone on a reflector at range r (two-way path), m."""
|
|
133
|
+
return np.sqrt(wavelength * r / 2 + wavelength**2 / 16)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def size_factor(kind: str, r: np.ndarray, wavelength: float, a: float = 0.0, width: float = 0.0,
|
|
137
|
+
length: float = 0.0) -> np.ndarray:
|
|
138
|
+
"""Echo of the target relative to an infinite flat reflector at the same range (see the module notes).
|
|
139
|
+
|
|
140
|
+
kind: "sphere" (radius a), "cylinder" (horizontal, radius a), "disc" (flat top of radius a), "strip" (flat top of
|
|
141
|
+
width ``width``, long along strike), "rectangle" (flat top ``width`` x ``length``), "plane" (infinite).
|
|
142
|
+
"""
|
|
143
|
+
r = np.asarray(r, float)
|
|
144
|
+
ka = 2 * np.pi * a / wavelength
|
|
145
|
+
if kind == "sphere":
|
|
146
|
+
return a / (r + a) * min(1.0, ka) ** 2
|
|
147
|
+
if kind == "cylinder":
|
|
148
|
+
return np.sqrt(a / (r + a)) * min(1.0, ka) ** 1.5
|
|
149
|
+
rf = fresnel_radius(wavelength, r)
|
|
150
|
+
if kind == "disc":
|
|
151
|
+
return np.minimum(1.0, (a / rf) ** 2)
|
|
152
|
+
if kind == "strip":
|
|
153
|
+
return np.minimum(1.0, width / (2 * rf))
|
|
154
|
+
if kind == "rectangle":
|
|
155
|
+
return np.minimum(1.0, width * length / (np.pi * rf**2))
|
|
156
|
+
if kind == "plane":
|
|
157
|
+
return np.ones_like(r)
|
|
158
|
+
raise ValueError(f"unknown target kind {kind!r}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def echo(r: np.ndarray, host: Medium, R: complex, factor: np.ndarray, f: float) -> np.ndarray:
|
|
162
|
+
"""Echo amplitude relative to the amplitude 1 m from the antenna, for reflecting surfaces at ranges r (m)."""
|
|
163
|
+
r = np.maximum(np.asarray(r, float), 1e-3)
|
|
164
|
+
alpha = host.attenuation_db(f) / NEPER_TO_DB
|
|
165
|
+
return abs(R) * factor * np.exp(-2 * alpha * r) / (2 * r)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def frequency_for_depth(depth: float) -> float:
|
|
169
|
+
"""Antenna centre frequency (MHz) for a target depth, from the depth-frequency rule, snapped to a common antenna."""
|
|
170
|
+
d, fr = np.array(DEPTH_TO_FREQUENCY_MHZ).T
|
|
171
|
+
f = float(np.exp(np.interp(np.log(max(depth, 1e-3)), np.log(d), np.log(fr))))
|
|
172
|
+
return min(ANTENNAS_MHZ, key=lambda a: abs(np.log(a / f)))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def two_way_time_ns(depth: float, velocity: float) -> float:
|
|
176
|
+
return 2 * depth / velocity * 1e9
|
|
@@ -17,10 +17,11 @@ from dataclasses import asdict, dataclass, field, fields, replace
|
|
|
17
17
|
|
|
18
18
|
import numpy as np
|
|
19
19
|
|
|
20
|
-
from . import __version__, detect, gravity, igrf, magnetics, rocks
|
|
20
|
+
from . import __version__, detect, gpr, gravity, igrf, magnetics, rocks
|
|
21
21
|
from .constants import SI_TO_UGAL
|
|
22
22
|
|
|
23
|
-
METHODS = ("gravity", "magnetic")
|
|
23
|
+
METHODS = ("gravity", "magnetic", "gradient", "gpr") # gradient: magnetic vertical gradient; gpr: ground-penetrating radar
|
|
24
|
+
DEFAULT_METHODS = ("gravity", "magnetic")
|
|
24
25
|
|
|
25
26
|
SHAPES: dict[str, str] = {
|
|
26
27
|
"sphere": "Sphere (cavity, boulder, buried object). size = diameter.",
|
|
@@ -49,6 +50,10 @@ class Target:
|
|
|
49
50
|
host: str = "limestone" # surrounding material
|
|
50
51
|
density_contrast: float | None = None # kg/m^3, overrides the material tables
|
|
51
52
|
susceptibility_contrast: float | None = None # SI, overrides the material tables
|
|
53
|
+
permittivity: float | None = None # relative, of the target (GPR); overrides the material table
|
|
54
|
+
conductivity: float | None = None # mS/m, of the target (GPR)
|
|
55
|
+
host_permittivity: float | None = None # relative, of the host (GPR)
|
|
56
|
+
host_conductivity: float | None = None # mS/m, of the host (GPR)
|
|
52
57
|
|
|
53
58
|
def validate(self) -> None:
|
|
54
59
|
if self.shape not in SHAPES:
|
|
@@ -72,10 +77,17 @@ class Survey:
|
|
|
72
77
|
magnetic_field_noise: float = detect.DEFAULT_FIELD_NOISE_NT # nT
|
|
73
78
|
magnetic_instrument_noise: float | None = None # nT, overrides the magnetometer preset
|
|
74
79
|
sensor_height: float = 0.5 # m, magnetometer sensor above ground
|
|
80
|
+
gradiometer: str = "Bartington Grad601"
|
|
81
|
+
gradient_field_noise: float = detect.DEFAULT_FIELD_NOISE_GRADIENT_NT # nT, of the gradiometer reading
|
|
82
|
+
gradient_instrument_noise: float | None = None # nT, overrides the gradiometer preset
|
|
83
|
+
gradiometer_height: float = 0.2 # m, lower sensor above ground (Bartington: about 20 cm in normal use)
|
|
84
|
+
gradiometer_baseline: float | None = None # m between the sensors; default: the gradiometer preset's
|
|
75
85
|
profile_azimuth: float = 0.0 # degrees east of north; 2D targets strike at right angles to it
|
|
76
86
|
latitude: float | None = None # for the main field (IGRF-14); needed for magnetics
|
|
77
87
|
longitude: float | None = None
|
|
78
88
|
date: str | None = None # ISO date of the survey; default: today
|
|
89
|
+
gpr_frequency: float | None = None # MHz, antenna centre frequency; default: from the target depth
|
|
90
|
+
gpr_system_performance: float = gpr.DEFAULT_SYSTEM_PERFORMANCE_DB # dB, largest two-way loss the system records
|
|
79
91
|
field_total: float | None = None # nT; main field given directly instead of IGRF (all three together)
|
|
80
92
|
field_inclination: float | None = None
|
|
81
93
|
field_declination: float | None = None
|
|
@@ -95,6 +107,7 @@ class MethodResult:
|
|
|
95
107
|
x: list[float] = field(default_factory=list)
|
|
96
108
|
anomaly: list[float] = field(default_factory=list)
|
|
97
109
|
assessment: detect.Assessment | None = None
|
|
110
|
+
details: dict | None = None # method-specific figures (GPR: frequency, velocity, attenuation, ...)
|
|
98
111
|
|
|
99
112
|
|
|
100
113
|
@dataclass
|
|
@@ -123,8 +136,14 @@ class Report:
|
|
|
123
136
|
lines.append(f"- {r.method}: not assessed ({r.reason})")
|
|
124
137
|
continue
|
|
125
138
|
a = r.assessment
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
if r.method == "gpr":
|
|
140
|
+
d = r.details
|
|
141
|
+
margin = 20 * np.log10(a.snr) if a.snr > 0 else float("-inf")
|
|
142
|
+
lines.append(f"- gpr ({d['frequency_mhz']:g} MHz): echo {margin:+.1f} dB against the detection limit, "
|
|
143
|
+
f"S/N {a.snr:.3g}: {a.verdict}")
|
|
144
|
+
else:
|
|
145
|
+
lines.append(f"- {r.method}: peak {a.peak:.3g} {a.units}, noise {a.noise:.3g} {a.units}, "
|
|
146
|
+
f"S/N {a.snr:.3g}: {a.verdict}")
|
|
128
147
|
if a.verdict != "not detectable":
|
|
129
148
|
lines.append(f" stations every {a.station_spacing:g} m or less, profile at least "
|
|
130
149
|
f"{a.profile_length:.0f} m long")
|
|
@@ -261,7 +280,106 @@ def _main_field(s: Survey):
|
|
|
261
280
|
"inclination": round(f.inclination, 3), "declination": round(f.declination, 3)}
|
|
262
281
|
|
|
263
282
|
|
|
264
|
-
def
|
|
283
|
+
def _gradient(t: Target, x, chi, amb, s: Survey, baseline: float):
|
|
284
|
+
"""Gradiometer reading: total-field anomaly at the lower sensor minus that at the upper sensor, in nT."""
|
|
285
|
+
low, high = replace(s, sensor_height=s.gradiometer_height), replace(s, sensor_height=s.gradiometer_height + baseline)
|
|
286
|
+
return _magnetic(t, x, chi, amb, low) - _magnetic(t, x, chi, amb, high)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _gpr_medium(name: str, permittivity, conductivity):
|
|
290
|
+
"""Medium from given properties or the GPR table; None for a metal (perfect conductor)."""
|
|
291
|
+
if permittivity is not None:
|
|
292
|
+
return gpr.Medium(permittivity, (conductivity or 0.0) / 1000.0)
|
|
293
|
+
if name in gpr.METALS:
|
|
294
|
+
return None
|
|
295
|
+
m = gpr.medium(name)
|
|
296
|
+
return m if conductivity is None else gpr.Medium(m.permittivity, conductivity / 1000.0)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _gpr(t: Target, s: Survey, x) -> MethodResult:
|
|
300
|
+
"""Ground-penetrating radar: echo of the target's top surface against the system's detection limit."""
|
|
301
|
+
if t.shape == "contact":
|
|
302
|
+
return MethodResult("gpr", False, "a vertical contact has no horizontal surface to reflect the radar wave; "
|
|
303
|
+
"GPR sees it only as a diffraction at its top edge, which is not modelled")
|
|
304
|
+
if t.depth_top <= 0:
|
|
305
|
+
return MethodResult("gpr", False, "GPR needs the target below the surface (depth_top > 0)")
|
|
306
|
+
try:
|
|
307
|
+
if t.host in gpr.METALS:
|
|
308
|
+
raise KeyError(f"the host cannot be a metal for GPR")
|
|
309
|
+
host = _gpr_medium(t.host, t.host_permittivity, t.host_conductivity)
|
|
310
|
+
target = _gpr_medium(t.material, t.permittivity, t.conductivity)
|
|
311
|
+
except KeyError as e:
|
|
312
|
+
return MethodResult("gpr", False, str(e.args[0]) + " (give permittivity and conductivity directly)")
|
|
313
|
+
if s.gpr_system_performance <= 0 or (s.gpr_frequency is not None and s.gpr_frequency <= 0):
|
|
314
|
+
raise ValueError("gpr_frequency and gpr_system_performance must be positive")
|
|
315
|
+
|
|
316
|
+
f_mhz = s.gpr_frequency or gpr.frequency_for_depth(t.depth_top)
|
|
317
|
+
f = f_mhz * 1e6
|
|
318
|
+
R = gpr.reflection(host, target, f)
|
|
319
|
+
lam = host.wavelength(f)
|
|
320
|
+
v = host.velocity(f)
|
|
321
|
+
a = (t.size or 0) / 2
|
|
322
|
+
if t.shape in ("sphere", "horizontal_cylinder"):
|
|
323
|
+
centre = t.depth_top + a
|
|
324
|
+
r = np.hypot(x, centre) - a
|
|
325
|
+
factor = gpr.size_factor("sphere" if t.shape == "sphere" else "cylinder", r, lam, a=a)
|
|
326
|
+
else:
|
|
327
|
+
r = np.hypot(x, t.depth_top)
|
|
328
|
+
if t.shape == "vertical_cylinder":
|
|
329
|
+
factor = gpr.size_factor("disc", r, lam, a=a)
|
|
330
|
+
elif t.shape == "prism":
|
|
331
|
+
factor = gpr.size_factor("rectangle", r, lam, width=t.size, length=t.length or t.size)
|
|
332
|
+
else:
|
|
333
|
+
factor = gpr.size_factor("strip", r, lam, width=t.size)
|
|
334
|
+
amplitude = gpr.echo(r, host, R, factor, f)
|
|
335
|
+
floor = 10 ** (-s.gpr_system_performance / 20)
|
|
336
|
+
snr_profile = amplitude / floor
|
|
337
|
+
a_ = detect.assess(x, snr_profile, 1.0, depth=t.depth_top, method="gpr")
|
|
338
|
+
nyquist = lam / 4
|
|
339
|
+
a_.station_spacing = detect._nice(min(a_.width / detect.STATIONS_ACROSS, nyquist))
|
|
340
|
+
t0 = gpr.two_way_time_ns(t.depth_top, v)
|
|
341
|
+
window = 1.3 * gpr.two_way_time_ns(max(t.depth_top + (t.size or 0), t.depth_top), v)
|
|
342
|
+
alpha = float(host.attenuation_db(f))
|
|
343
|
+
k_a = 2 * np.pi * a / lam
|
|
344
|
+
a_.assumptions = [
|
|
345
|
+
f"Signal-to-noise thresholds: >= {detect.CLEAR:g} clearly detectable, {detect.MARGINAL:g}-{detect.CLEAR:g} "
|
|
346
|
+
f"marginal, < {detect.MARGINAL:g} not detectable; S/N is the echo over the system's detection limit.",
|
|
347
|
+
f"System performance {s.gpr_system_performance:g} dB (largest two-way loss recorded, referred to 1 m; "
|
|
348
|
+
"typical systems 90-130 dB). It includes antenna gain and coupling; clutter and noise at the site lower it.",
|
|
349
|
+
f"Antenna {f_mhz:g} MHz" + ("" if s.gpr_frequency else " (chosen from the target depth)") +
|
|
350
|
+
f"; host: relative permittivity {host.permittivity:g}, conductivity {host.conductivity * 1000:g} mS/m, "
|
|
351
|
+
f"velocity {v / 1e9:.3g} m/ns, attenuation {alpha:.3g} dB/m (one way).",
|
|
352
|
+
"Reflection coefficient " + ("-1 (metal)" if target is None else f"{abs(R):.3g} (normal incidence, "
|
|
353
|
+
f"target permittivity {target.permittivity:g}, conductivity "
|
|
354
|
+
f"{target.conductivity * 1000:g} mS/m)") + ".",
|
|
355
|
+
"Homogeneous host with frequency-independent properties; real soils lose more at high frequency, so wet or "
|
|
356
|
+
"clayey ground above ~500 MHz is optimistic.",
|
|
357
|
+
f"Trace spacing at most a quarter wavelength in the host ({nyquist:.3g} m) and {detect.STATIONS_ACROSS} traces "
|
|
358
|
+
"across the echo.",
|
|
359
|
+
f"Two-way time to the target top {t0:.3g} ns; record at least {window:.3g} ns (30 % margin).",
|
|
360
|
+
]
|
|
361
|
+
if t.shape in ("sphere", "horizontal_cylinder"):
|
|
362
|
+
a_.assumptions.append("Curved target: geometric optics" + (f"; small against the wavelength (k a = {k_a:.2g}), "
|
|
363
|
+
"Rayleigh scaling applied" if k_a < 1 else "") + ".")
|
|
364
|
+
else:
|
|
365
|
+
a_.assumptions.append("Echo of the flat top surface; a top smaller than the first Fresnel zone returns the "
|
|
366
|
+
"fraction of the zone it covers.")
|
|
367
|
+
if (t.size or 0) < nyquist:
|
|
368
|
+
a_.assumptions.append(f"The target ({t.size:g} m) is smaller than the vertical resolution (a quarter "
|
|
369
|
+
f"wavelength, {nyquist:.3g} m): it shows as a diffraction hyperbola, its size is not resolved.")
|
|
370
|
+
if t.shape in TWO_D:
|
|
371
|
+
a_.assumptions.append("2D target: infinitely long along strike, at right angles to the profile.")
|
|
372
|
+
details = {"frequency_mhz": float(f_mhz), "velocity_m_per_ns": float(v / 1e9), "attenuation_db_per_m": alpha,
|
|
373
|
+
"wavelength_m": float(lam), "reflection_coefficient": float(abs(R)), "two_way_time_ns": float(t0),
|
|
374
|
+
"time_window_ns": float(window), "vertical_resolution_m": float(nyquist),
|
|
375
|
+
"margin_db": float(20 * np.log10(a_.snr)) if a_.snr > 0 else None,
|
|
376
|
+
"system_performance_db": s.gpr_system_performance}
|
|
377
|
+
return MethodResult("gpr", True, contrast=abs(R), contrast_units="reflection coefficient",
|
|
378
|
+
instrument=f"{f_mhz:g} MHz antenna", x=x.round(4).tolist(), anomaly=snr_profile.tolist(),
|
|
379
|
+
assessment=a_, details=details)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
|
|
265
383
|
"""Model ``target`` (a ``Target`` or dict) for each method and assess it for ``survey`` (a ``Survey`` or dict)."""
|
|
266
384
|
t, s = _as(Target, target), _as(Survey, survey)
|
|
267
385
|
t.validate()
|
|
@@ -275,6 +393,9 @@ def evaluate(target, survey=None, methods=METHODS) -> Report:
|
|
|
275
393
|
x = _profile(t)
|
|
276
394
|
results = []
|
|
277
395
|
for method in methods:
|
|
396
|
+
if method == "gpr":
|
|
397
|
+
results.append(_gpr(t, s, x))
|
|
398
|
+
continue
|
|
278
399
|
contrast, units, problem = _contrast(t, method)
|
|
279
400
|
if contrast is None:
|
|
280
401
|
results.append(MethodResult(method, False, problem))
|
|
@@ -295,18 +416,36 @@ def evaluate(target, survey=None, methods=METHODS) -> Report:
|
|
|
295
416
|
results.append(MethodResult(method, False, "needs the survey location (latitude, longitude) for the "
|
|
296
417
|
"main field, or field_total/inclination/declination"))
|
|
297
418
|
continue
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
419
|
+
if method == "magnetic":
|
|
420
|
+
inst = s.magnetic_instrument_noise if s.magnetic_instrument_noise is not None else \
|
|
421
|
+
detect.MAGNETOMETERS_NT.get(s.magnetometer)
|
|
422
|
+
if inst is None:
|
|
423
|
+
results.append(MethodResult(method, False, f"unknown magnetometer {s.magnetometer!r}"))
|
|
424
|
+
continue
|
|
425
|
+
noise = detect.effective_noise(inst, s.magnetic_field_noise)
|
|
426
|
+
anomaly = _magnetic(t, x, contrast, amb, s)
|
|
427
|
+
res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.magnetometer,
|
|
428
|
+
instrument_noise=inst, field_noise=s.magnetic_field_noise, main_field=info)
|
|
429
|
+
else:
|
|
430
|
+
preset = detect.INSTRUMENTS.get(s.gradiometer)
|
|
431
|
+
inst = s.gradient_instrument_noise if s.gradient_instrument_noise is not None else \
|
|
432
|
+
(preset.noise if preset and preset.method == "gradient" else None)
|
|
433
|
+
baseline = s.gradiometer_baseline if s.gradiometer_baseline is not None else \
|
|
434
|
+
(preset.baseline if preset and preset.method == "gradient" else None)
|
|
435
|
+
if inst is None or baseline is None:
|
|
436
|
+
results.append(MethodResult(method, False, f"unknown gradiometer {s.gradiometer!r} (or give "
|
|
437
|
+
"gradient_instrument_noise and gradiometer_baseline)"))
|
|
438
|
+
continue
|
|
439
|
+
if baseline <= 0 or s.gradiometer_height < 0:
|
|
440
|
+
raise ValueError("gradiometer_baseline must be positive and gradiometer_height zero or positive")
|
|
441
|
+
noise = detect.effective_noise(inst, s.gradient_field_noise)
|
|
442
|
+
anomaly = _gradient(t, x, contrast, amb, s, baseline)
|
|
443
|
+
res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.gradiometer,
|
|
444
|
+
instrument_noise=inst, field_noise=s.gradient_field_noise, main_field=info)
|
|
307
445
|
a = detect.assess(x, anomaly, noise, depth=t.depth_top, method=method)
|
|
308
446
|
preset = detect.INSTRUMENTS.get(res.instrument)
|
|
309
|
-
overridden =
|
|
447
|
+
overridden = {"gravity": s.gravity_instrument_noise, "magnetic": s.magnetic_instrument_noise,
|
|
448
|
+
"gradient": s.gradient_instrument_noise}[method] is not None
|
|
310
449
|
if preset and not overridden:
|
|
311
450
|
a.assumptions.append(f"{res.instrument}: {preset.noise:g} {a.units} per reading ({preset.basis}; "
|
|
312
451
|
f"{preset.reference} data sheet).")
|
|
@@ -315,6 +454,11 @@ def evaluate(target, survey=None, methods=METHODS) -> Report:
|
|
|
315
454
|
if method == "magnetic":
|
|
316
455
|
a.assumptions.append(f"Sensor {s.sensor_height:g} m above ground; profile azimuth {s.profile_azimuth:g}°; "
|
|
317
456
|
"induced magnetisation only, no demagnetisation.")
|
|
457
|
+
if method == "gradient":
|
|
458
|
+
top = s.gradiometer_height + baseline
|
|
459
|
+
a.assumptions.append(f"Reading: lower sensor ({s.gradiometer_height:g} m above ground) minus upper sensor "
|
|
460
|
+
f"({top:g} m), in nT over the {baseline:g} m baseline; diurnal variation and the regional "
|
|
461
|
+
f"field cancel. Profile azimuth {s.profile_azimuth:g}°; induced magnetisation only.")
|
|
318
462
|
res.x, res.anomaly, res.assessment = x.round(4).tolist(), anomaly.tolist(), a
|
|
319
463
|
results.append(res)
|
|
320
464
|
|
|
@@ -18,7 +18,7 @@ from importlib import resources
|
|
|
18
18
|
|
|
19
19
|
import numpy as np
|
|
20
20
|
|
|
21
|
-
from . import gravity, igrf, magnetics
|
|
21
|
+
from . import gpr, gravity, igrf, magnetics
|
|
22
22
|
from .constants import G, SI_TO_UGAL
|
|
23
23
|
from .magnetics import MU0, Ambient
|
|
24
24
|
|
|
@@ -240,6 +240,24 @@ def cases() -> list[Case]:
|
|
|
240
240
|
gravity.dyke(x, 3, 15, 4, 500.0, centre=7) * SI_TO_UGAL,
|
|
241
241
|
gravity.polygon(x, [(5, 3), (9, 3), (9, 15), (5, 15)], 500.0) * SI_TO_UGAL))
|
|
242
242
|
|
|
243
|
+
# gradiometer reading of a sphere at the pole: closed-form dipole field at the two sensor heights
|
|
244
|
+
x = np.linspace(-20, 20, 81)
|
|
245
|
+
depth, radius, low, base = 3.0, 0.5, 0.2, 1.0
|
|
246
|
+
m = CHI * POLE.total * 1e-9 / MU0 * 4 / 3 * np.pi * radius**3
|
|
247
|
+
|
|
248
|
+
def _pole_dipole(h):
|
|
249
|
+
z = depth + h # depth of the centre below the sensor
|
|
250
|
+
return MU0 / (4 * np.pi) * m * (2 * z**2 - x**2) / (x**2 + z**2) ** 2.5 * 1e9
|
|
251
|
+
|
|
252
|
+
from .survey import _gradient, Survey, Target
|
|
253
|
+
sphere_target = Target("sphere", depth - radius, 2 * radius)
|
|
254
|
+
reading = _gradient(sphere_target, x, CHI, POLE, Survey(gradiometer_height=low), base)
|
|
255
|
+
out.append(_profile_case(
|
|
256
|
+
"gradient-sphere-pole", "Independent formula", "Sphere, magnetic gradient (gradiometer reading)",
|
|
257
|
+
"Total-field model at the lower minus the upper sensor", "Closed-form vertical-dipole field at both sensors",
|
|
258
|
+
"Radius 0.5 m, centre at 3 m, sensors 0.2 and 1.2 m above ground, at the pole", "nT", 1e-10, x, reading,
|
|
259
|
+
_pole_dipole(low) - _pole_dipole(low + base)))
|
|
260
|
+
|
|
243
261
|
# --- numerical integration -------------------------------------------------------------------------------
|
|
244
262
|
x = np.linspace(-40, 40, 81)
|
|
245
263
|
box = ((-3.0, 3.0), (-4.0, 4.0), (2.0, 6.0))
|
|
@@ -312,6 +330,40 @@ def cases() -> list[Case]:
|
|
|
312
330
|
magnetics.contact(x, 5.0, 40.0, CHI, POLE),
|
|
313
331
|
_poisson(_dgz_dz(lambda a, b: gravity.vertical_step(x, a, b, 1000.0), 5.0, 40.0), CHI, POLE, 1000.0)))
|
|
314
332
|
|
|
333
|
+
# --- ground-penetrating radar --------------------------------------------------------------------------------
|
|
334
|
+
lime = gpr.medium("limestone")
|
|
335
|
+
out.append(Case("gpr-low-loss", "Analytical limit", "GPR, attenuation in a low-loss host",
|
|
336
|
+
"Exact complex wavenumber", "Low-loss limit 1636 σ/√εr dB/m (σ in S/m)", "Limestone (εr 6, 1 mS/m), 1 GHz",
|
|
337
|
+
"dB/m", 0.01, _value_error(lime.attenuation_db(1e9),
|
|
338
|
+
gpr.low_loss_attenuation_db(lime.permittivity, lime.conductivity))))
|
|
339
|
+
sand = gpr.medium("dry sand")
|
|
340
|
+
out.append(Case("gpr-velocity", "Analytical limit", "GPR, velocity in a low-loss host", "Exact complex wavenumber",
|
|
341
|
+
"c/√εr", "Dry sand (εr 4, 0.01 mS/m), 100 MHz", "m/ns", 1e-4,
|
|
342
|
+
_value_error(sand.velocity(1e8), gpr.C0 / np.sqrt(sand.permittivity))))
|
|
343
|
+
air = gpr.Medium(1.0, 0.0)
|
|
344
|
+
dry_lime = gpr.Medium(lime.permittivity, 0.0)
|
|
345
|
+
out.append(Case("gpr-reflection", "Analytical limit", "GPR, reflection coefficient", "Ratio of complex impedances",
|
|
346
|
+
"(√ε1 − √ε2)/(√ε1 + √ε2) for lossless media", "Air void in limestone (εr 6 and 1)", "", 1e-10,
|
|
347
|
+
_value_error(abs(gpr.reflection(dry_lime, air, 1e8)),
|
|
348
|
+
(np.sqrt(6.0) - 1.0) / (np.sqrt(6.0) + 1.0))))
|
|
349
|
+
r = np.linspace(1.0, 50.0, 50)
|
|
350
|
+
out.append(Case(
|
|
351
|
+
"gpr-sphere-plane", "Analytical limit", "GPR, very large sphere", "Geometric optics (convex mirror)",
|
|
352
|
+
"Flat reflector at the same range", "Radius 10⁹ m, ranges 1–50 m, 100 MHz in limestone", "", 1e-4,
|
|
353
|
+
_profile_error(gpr.size_factor("sphere", r, lime.wavelength(1e8), a=1e9),
|
|
354
|
+
gpr.size_factor("plane", r, lime.wavelength(1e8)))))
|
|
355
|
+
# Davis & Annan (1989) table, as reproduced in USACE EM 1110-1-1802 (1995) Table 4-3: velocity in m/ns.
|
|
356
|
+
table_v = {"dry sand": 0.15, "wet sand": 0.06, "limestone": 0.12, "shale": 0.09, "silt": 0.07, "clay": 0.06,
|
|
357
|
+
"granite": 0.13, "rock salt": 0.13, "ice": 0.16, "fresh water": 0.033}
|
|
358
|
+
names = list(table_v)
|
|
359
|
+
model_v = np.array([gpr.medium(n).velocity(1e8) / 1e9 for n in names])
|
|
360
|
+
ref_v = np.array([table_v[n] for n in names])
|
|
361
|
+
out.append(Case("gpr-table-velocity", "Published values", "GPR, velocity of common materials",
|
|
362
|
+
"Exact velocity from the typical εr and σ, 100 MHz",
|
|
363
|
+
"Davis & Annan (1989) table velocities (rounded to 0.01 m/ns)",
|
|
364
|
+
"Ten materials, from dry sand to fresh water", "m/ns", 6.0,
|
|
365
|
+
float(np.max(np.abs(model_v - ref_v) / ref_v * 100.0))))
|
|
366
|
+
|
|
315
367
|
# --- independent code ---------------------------------------------------------------------------------------
|
|
316
368
|
out.extend(_harmonica_cases())
|
|
317
369
|
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detectability
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -9,6 +9,7 @@ Project-URL: Documentation, https://detectability.mukreminnergiz.com/method
|
|
|
9
9
|
Project-URL: Validation, https://detectability.mukreminnergiz.com/validation
|
|
10
10
|
Project-URL: Repository, https://github.com/mukremin-nergiz/detectability
|
|
11
11
|
Project-URL: Issues, https://github.com/mukremin-nergiz/detectability/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md
|
|
12
13
|
Project-URL: Citation, https://doi.org/10.5281/zenodo.23931815
|
|
13
14
|
Keywords: geophysics,gravity,magnetics,survey design,near-surface,forward modelling,IGRF
|
|
14
15
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -33,9 +34,9 @@ Dynamic: license-file
|
|
|
33
34
|
|
|
34
35
|
# Detectability
|
|
35
36
|
|
|
37
|
+
[](https://doi.org/10.5281/zenodo.23931815)
|
|
36
38
|
[](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
|
|
37
39
|
[](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
|
|
38
|
-
[](https://doi.org/10.5281/zenodo.23931815)
|
|
39
40
|
|
|
40
41
|
**Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
|
|
41
42
|
magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
|
|
@@ -68,7 +69,7 @@ print(report.summary())
|
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
```text
|
|
71
|
-
Detectability 0.
|
|
72
|
+
Detectability 0.4.0: sphere in limestone
|
|
72
73
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
73
74
|
stations every 5 m or less, profile at least 153 m long
|
|
74
75
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -82,16 +83,27 @@ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `d
|
|
|
82
83
|
geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
|
|
83
84
|
in, and measured contrasts can be given instead.
|
|
84
85
|
|
|
86
|
+
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
87
|
+
(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; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
|
|
89
|
+
the system's detection limit (radar range equation), with velocity, attenuation, antenna frequency, trace spacing and
|
|
90
|
+
recording window. The defaults are `gravity` and `magnetic`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
94
|
+
```
|
|
95
|
+
|
|
85
96
|
## Modules
|
|
86
97
|
|
|
87
98
|
| Module | Contents |
|
|
88
99
|
|---|---|
|
|
89
100
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
90
101
|
| `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) |
|
|
91
|
-
| `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation |
|
|
102
|
+
| `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
|
+
| `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) |
|
|
92
104
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
93
105
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
94
|
-
| `detect` | instrument
|
|
106
|
+
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
95
107
|
|
|
96
108
|
## Validation
|
|
97
109
|
|
|
@@ -117,6 +129,7 @@ python tools/harmonica_reference.py --check
|
|
|
117
129
|
| Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
|
|
118
130
|
| Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
|
|
119
131
|
| Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
|
|
132
|
+
| Independent formula | Sphere, magnetic gradient (gradiometer reading) | Closed-form vertical-dipole field at both sensors | 7.8e-14 % | 1.0e-10 % |
|
|
120
133
|
| Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
|
|
121
134
|
| Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
|
|
122
135
|
| Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
|
|
@@ -127,6 +140,11 @@ python tools/harmonica_reference.py --check
|
|
|
127
140
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
128
141
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
129
142
|
| Poisson's relation | Vertical contact, gravity ↔ magnetic | Vertical derivative of the gravity step | 3.9e-06 % | 1.0e-04 % |
|
|
143
|
+
| Analytical limit | GPR, attenuation in a low-loss host | Low-loss limit 1636 σ/√εr dB/m (σ in S/m) | 1.1e-04 % | 0.01 % |
|
|
144
|
+
| Analytical limit | GPR, velocity in a low-loss host | c/√εr | 2.5e-06 % | 1.0e-04 % |
|
|
145
|
+
| Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
|
|
146
|
+
| Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
|
|
147
|
+
| Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
|
|
130
148
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
131
149
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
132
150
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -147,16 +165,18 @@ pytest
|
|
|
147
165
|
Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
|
|
148
166
|
crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
|
|
149
167
|
not guarantees. Instrument presets come from manufacturers' data sheets (`detect.INSTRUMENTS` lists each figure and its
|
|
150
|
-
source); field noise, which usually dominates, is a stated assumption that you can change.
|
|
168
|
+
source); field noise, which usually dominates, is a stated assumption that you can change. For gradiometers the default
|
|
169
|
+
field noise (0.5 nT) is an assumption, not a published figure: soil, heading and walking noise depend on the site.
|
|
151
170
|
|
|
152
171
|
## Citation
|
|
153
172
|
|
|
154
173
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
155
174
|
|
|
156
|
-
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.
|
|
175
|
+
> Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
|
|
157
176
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
158
177
|
|
|
159
|
-
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
|
|
178
|
+
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
179
|
+
releases: [CHANGELOG.md](https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md).
|
|
160
180
|
|
|
161
181
|
## Author
|
|
162
182
|
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/detectability/__init__.py
|
|
5
5
|
src/detectability/constants.py
|
|
6
6
|
src/detectability/detect.py
|
|
7
|
+
src/detectability/gpr.py
|
|
7
8
|
src/detectability/gravity.py
|
|
8
9
|
src/detectability/igrf.py
|
|
9
10
|
src/detectability/magnetics.py
|
|
@@ -18,6 +19,7 @@ src/detectability.egg-info/top_level.txt
|
|
|
18
19
|
src/detectability/data/harmonica_reference.json
|
|
19
20
|
src/detectability/data/igrf14coeffs.txt
|
|
20
21
|
tests/test_detect.py
|
|
22
|
+
tests/test_gpr.py
|
|
21
23
|
tests/test_gravity.py
|
|
22
24
|
tests/test_igrf.py
|
|
23
25
|
tests/test_magnetics.py
|
|
@@ -66,8 +66,17 @@ def test_void_in_limestone_magnetic_example():
|
|
|
66
66
|
def test_instrument_presets_have_sources():
|
|
67
67
|
assert detect.INSTRUMENTS
|
|
68
68
|
for name, inst in detect.INSTRUMENTS.items():
|
|
69
|
-
assert inst.method in ("gravity", "magnetic"), name
|
|
69
|
+
assert inst.method in ("gravity", "magnetic", "gradient"), name
|
|
70
70
|
assert inst.noise > 0 and inst.source.startswith("https://") and inst.reference, name
|
|
71
71
|
assert detect.GRAVIMETERS_UGAL["Scintrex CG-6"] == 5.0
|
|
72
72
|
assert detect.MAGNETOMETERS_NT["Overhauser"] == 0.022
|
|
73
|
-
assert "
|
|
73
|
+
assert "Bartington Grad601" not in detect.MAGNETOMETERS_NT # a gradiometer reading, not the total field
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_gradiometer_presets_have_a_baseline_and_a_source():
|
|
77
|
+
grads = {k: v for k, v in detect.INSTRUMENTS.items() if v.method == "gradient"}
|
|
78
|
+
assert len(grads) >= 3 and set(grads) == set(detect.GRADIOMETERS_NT)
|
|
79
|
+
for name, inst in grads.items():
|
|
80
|
+
assert inst.baseline and 0.3 <= inst.baseline <= 1.5, name
|
|
81
|
+
assert inst.source.startswith("https://") and inst.noise > 0, name
|
|
82
|
+
assert all(v.baseline is None for v in detect.INSTRUMENTS.values() if v.method != "gradient")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from detectability import evaluate, gpr
|
|
5
|
+
|
|
6
|
+
SITE = {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_attenuation_within_published_ranges():
|
|
10
|
+
# Davis & Annan (1989) table (USACE 1995 Table 4-3): attenuation ranges in dB/m
|
|
11
|
+
ranges = {"dry sand": (0.005, 0.02), "wet sand": (0.03, 0.3), "limestone": (0.4, 1.0), "shale": (1, 100),
|
|
12
|
+
"silt": (1, 100), "clay": (1, 300), "granite": (0.01, 1), "rock salt": (0.01, 1), "fresh water": (0.05, 0.15)}
|
|
13
|
+
for name, (lo, hi) in ranges.items():
|
|
14
|
+
assert lo <= gpr.medium(name).attenuation_db(1e8) <= hi, name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_sea_water_is_opaque():
|
|
18
|
+
assert gpr.medium("sea water").attenuation_db(1e8) > 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_reflection_limits():
|
|
22
|
+
lime = gpr.medium("limestone")
|
|
23
|
+
assert abs(gpr.reflection(lime, lime, 1e8)) == pytest.approx(0, abs=1e-12)
|
|
24
|
+
assert gpr.reflection(lime, None, 1e8) == -1
|
|
25
|
+
# a faster (lower permittivity) target gives a positive coefficient, a slower one negative
|
|
26
|
+
assert gpr.reflection(lime, gpr.medium("air"), 1e8).real > 0
|
|
27
|
+
assert gpr.reflection(lime, gpr.medium("fresh water"), 1e8).real < 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_size_factor_limits():
|
|
31
|
+
r = np.array([1.0, 5.0, 20.0])
|
|
32
|
+
lam = 0.5
|
|
33
|
+
assert np.allclose(gpr.size_factor("sphere", r, lam, a=1e9), 1.0)
|
|
34
|
+
assert np.allclose(gpr.size_factor("cylinder", r, lam, a=1e9), 1.0)
|
|
35
|
+
assert np.allclose(gpr.size_factor("disc", r, lam, a=1e3), 1.0)
|
|
36
|
+
small = gpr.size_factor("sphere", r, lam, a=0.01)
|
|
37
|
+
assert np.all(small < 1e-3)
|
|
38
|
+
# Rayleigh regime joins geometric optics continuously at k a = 1
|
|
39
|
+
a1 = lam / (2 * np.pi)
|
|
40
|
+
assert gpr.size_factor("sphere", r, lam, a=a1 * (1 - 1e-9)) == pytest.approx(gpr.size_factor("sphere", r, lam, a=a1))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_frequency_for_depth_follows_the_rule():
|
|
44
|
+
assert gpr.frequency_for_depth(1.0) == 500
|
|
45
|
+
assert gpr.frequency_for_depth(10.0) == 50
|
|
46
|
+
assert gpr.frequency_for_depth(0.2) == 1000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_echo_falls_with_depth_and_attenuation():
|
|
50
|
+
lime, clay = gpr.medium("limestone"), gpr.medium("clay")
|
|
51
|
+
R = gpr.reflection(lime, gpr.medium("air"), 2e8)
|
|
52
|
+
one = np.ones(2)
|
|
53
|
+
e = gpr.echo(np.array([2.0, 8.0]), lime, R, one, 2e8)
|
|
54
|
+
assert e[0] > e[1]
|
|
55
|
+
assert gpr.echo(np.array([2.0]), clay, R, np.ones(1), 2e8)[0] < e[0]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_evaluate_gpr_void_in_limestone():
|
|
59
|
+
rep = evaluate({"shape": "sphere", "depth_top": 3, "size": 1, "material": "air", "host": "limestone"}, SITE,
|
|
60
|
+
methods=["gpr"])
|
|
61
|
+
r = rep.result("gpr")
|
|
62
|
+
assert r.available and r.details["frequency_mhz"] == 200
|
|
63
|
+
assert r.assessment.verdict == "clearly detectable"
|
|
64
|
+
assert r.assessment.station_spacing <= r.details["vertical_resolution_m"]
|
|
65
|
+
assert r.details["velocity_m_per_ns"] == pytest.approx(0.1224, abs=1e-3)
|
|
66
|
+
assert "gpr (200 MHz)" in rep.summary()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_deep_target_in_clay_is_not_detectable():
|
|
70
|
+
rep = evaluate({"shape": "sphere", "depth_top": 8, "size": 2, "material": "air", "host": "clay"}, SITE,
|
|
71
|
+
methods=["gpr"])
|
|
72
|
+
assert rep.result("gpr").assessment.verdict == "not detectable"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_metal_pipe_and_given_properties():
|
|
76
|
+
rep = evaluate({"shape": "horizontal_cylinder", "depth_top": 1, "size": 0.1, "material": "metal",
|
|
77
|
+
"host": "soil", "host_permittivity": 9, "host_conductivity": 5}, SITE, methods=["gpr", "gravity"])
|
|
78
|
+
r = rep.result("gpr")
|
|
79
|
+
assert r.available and r.contrast == pytest.approx(1.0)
|
|
80
|
+
assert not rep.result("gravity").available
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_unavailable_cases():
|
|
84
|
+
contact = evaluate({"shape": "contact", "depth_top": 1, "depth_bottom": 5, "host": "limestone"}, SITE, methods=["gpr"])
|
|
85
|
+
assert not contact.result("gpr").available
|
|
86
|
+
unknown = evaluate({"shape": "sphere", "depth_top": 2, "size": 1, "host": "gabbro"}, SITE, methods=["gpr"])
|
|
87
|
+
assert "permittivity" in unknown.result("gpr").reason
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_lower_system_performance_lowers_snr():
|
|
91
|
+
t = {"shape": "prism", "depth_top": 4, "depth_bottom": 6, "size": 3, "material": "air", "host": "limestone"}
|
|
92
|
+
hi = evaluate(t, {**SITE, "gpr_system_performance": 120}, methods=["gpr"]).result("gpr").assessment.snr
|
|
93
|
+
lo = evaluate(t, {**SITE, "gpr_system_performance": 80}, methods=["gpr"]).result("gpr").assessment.snr
|
|
94
|
+
assert hi / lo == pytest.approx(100.0) # 40 dB
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_script_reproduces_gpr():
|
|
98
|
+
rep = evaluate({"shape": "dyke", "depth_top": 2, "depth_bottom": 6, "size": 1, "material": "wet sand",
|
|
99
|
+
"host": "granite"}, {**SITE, "gpr_frequency": 250}, methods=["gpr"])
|
|
100
|
+
ns = {}
|
|
101
|
+
exec(rep.script().replace("print(report.summary())", "").split("# Optional plot")[0], ns)
|
|
102
|
+
assert ns["report"].result("gpr").assessment.snr == pytest.approx(rep.result("gpr").assessment.snr)
|
|
@@ -75,3 +75,43 @@ def test_dataclass_inputs_are_not_modified():
|
|
|
75
75
|
s = Survey(latitude=39.75, longitude=37.02)
|
|
76
76
|
evaluate(Target(**VOID), s)
|
|
77
77
|
assert s.date is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
SITE = {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}
|
|
81
|
+
SHALLOW_DYKE = {"shape": "dyke", "depth_top": 0.4, "depth_bottom": 1.2, "size": 0.8, "material": "basalt",
|
|
82
|
+
"host": "sandstone"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_default_methods_unchanged_and_gradient_on_request():
|
|
86
|
+
from detectability import DEFAULT_METHODS, METHODS
|
|
87
|
+
assert DEFAULT_METHODS == ("gravity", "magnetic") and "gradient" in METHODS
|
|
88
|
+
assert [r.method for r in evaluate(SHALLOW_DYKE, SITE).results] == ["gravity", "magnetic"]
|
|
89
|
+
r = evaluate(SHALLOW_DYKE, SITE, ["gradient"]).results[0]
|
|
90
|
+
assert r.available and r.instrument == "Bartington Grad601" and r.assessment.units == "nT"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_gradient_is_the_difference_of_two_sensor_heights():
|
|
94
|
+
import numpy as np
|
|
95
|
+
low = evaluate(SHALLOW_DYKE, {**SITE, "sensor_height": 0.3}, ["magnetic"]).results[0]
|
|
96
|
+
high = evaluate(SHALLOW_DYKE, {**SITE, "sensor_height": 0.95}, ["magnetic"]).results[0]
|
|
97
|
+
g = evaluate(SHALLOW_DYKE, {**SITE, "gradiometer": "Sensys FGM650", "gradiometer_height": 0.3}, ["gradient"])
|
|
98
|
+
assert np.allclose(g.results[0].anomaly, np.subtract(low.anomaly, high.anomaly), rtol=0, atol=1e-12)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_gradient_favours_shallow_targets():
|
|
102
|
+
deep = {"shape": "sphere", "depth_top": 15, "size": 20, "material": "air", "host": "limestone"}
|
|
103
|
+
shallow = evaluate(SHALLOW_DYKE, SITE, ["magnetic", "gradient"])
|
|
104
|
+
assert shallow.recommended == "gradient"
|
|
105
|
+
far = evaluate(deep, SITE, ["magnetic", "gradient"]).results
|
|
106
|
+
assert far[1].assessment.snr < far[0].assessment.snr
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_custom_gradiometer_and_errors():
|
|
110
|
+
import pytest
|
|
111
|
+
r = evaluate(SHALLOW_DYKE, {**SITE, "gradiometer": "home-made", "gradient_instrument_noise": 0.05,
|
|
112
|
+
"gradiometer_baseline": 0.5}, ["gradient"]).results[0]
|
|
113
|
+
assert r.available and r.instrument_noise == 0.05
|
|
114
|
+
assert not evaluate(SHALLOW_DYKE, {**SITE, "gradiometer": "home-made"}, ["gradient"]).results[0].available
|
|
115
|
+
assert not evaluate(SHALLOW_DYKE, {}, ["gradient"]).results[0].available # needs the location
|
|
116
|
+
with pytest.raises(ValueError):
|
|
117
|
+
evaluate(SHALLOW_DYKE, {**SITE, "gradiometer_baseline": 0}, ["gradient"])
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
"""Detectability: pre-survey detectability, modelled and reproducible."""
|
|
2
|
-
|
|
3
|
-
__version__ = "0.2.1"
|
|
4
|
-
|
|
5
|
-
from .survey import METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
|
|
6
|
-
|
|
7
|
-
__all__ = ["evaluate", "Target", "Survey", "Report", "SHAPES", "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
|