detectability 0.3.0__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.3.0/src/detectability.egg-info → detectability-0.4.0}/PKG-INFO +14 -6
- {detectability-0.3.0 → detectability-0.4.0}/README.md +13 -5
- {detectability-0.3.0 → detectability-0.4.0}/pyproject.toml +1 -1
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/__init__.py +1 -1
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/detect.py +1 -1
- detectability-0.4.0/src/detectability/gpr.py +176 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/survey.py +113 -4
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/validation.py +35 -1
- {detectability-0.3.0 → detectability-0.4.0/src/detectability.egg-info}/PKG-INFO +14 -6
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/SOURCES.txt +2 -0
- detectability-0.4.0/tests/test_gpr.py +102 -0
- {detectability-0.3.0 → detectability-0.4.0}/LICENSE +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/setup.cfg +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/constants.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/data/harmonica_reference.json +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/data/igrf14coeffs.txt +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/gravity.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/igrf.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/magnetics.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability/rocks.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/dependency_links.txt +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/requires.txt +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/top_level.txt +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/tests/test_detect.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/tests/test_gravity.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/tests/test_igrf.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/tests/test_magnetics.py +0 -0
- {detectability-0.3.0 → detectability-0.4.0}/tests/test_survey.py +0 -0
- {detectability-0.3.0 → 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
|
|
@@ -34,9 +34,9 @@ Dynamic: license-file
|
|
|
34
34
|
|
|
35
35
|
# Detectability
|
|
36
36
|
|
|
37
|
+
[](https://doi.org/10.5281/zenodo.23931815)
|
|
37
38
|
[](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
|
|
38
39
|
[](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
|
|
39
|
-
[](https://doi.org/10.5281/zenodo.23931815)
|
|
40
40
|
|
|
41
41
|
**Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
|
|
42
42
|
magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
|
|
@@ -69,7 +69,7 @@ print(report.summary())
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
```text
|
|
72
|
-
Detectability 0.
|
|
72
|
+
Detectability 0.4.0: sphere in limestone
|
|
73
73
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
74
74
|
stations every 5 m or less, profile at least 153 m long
|
|
75
75
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -85,10 +85,12 @@ in, and measured contrasts can be given instead.
|
|
|
85
85
|
|
|
86
86
|
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
87
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
|
|
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`:
|
|
89
91
|
|
|
90
92
|
```python
|
|
91
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
93
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
92
94
|
```
|
|
93
95
|
|
|
94
96
|
## Modules
|
|
@@ -98,6 +100,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
|
98
100
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
99
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) |
|
|
100
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) |
|
|
101
104
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
102
105
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
103
106
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -137,6 +140,11 @@ python tools/harmonica_reference.py --check
|
|
|
137
140
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
138
141
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
139
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 % |
|
|
140
148
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
141
149
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
142
150
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -164,7 +172,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
164
172
|
|
|
165
173
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
166
174
|
|
|
167
|
-
> 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].
|
|
168
176
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
169
177
|
|
|
170
178
|
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.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
|
|
@@ -51,10 +51,12 @@ 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. The defaults are `gravity` and `magnetic`:
|
|
55
57
|
|
|
56
58
|
```python
|
|
57
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
59
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
58
60
|
```
|
|
59
61
|
|
|
60
62
|
## Modules
|
|
@@ -64,6 +66,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
|
64
66
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
65
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) |
|
|
66
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) |
|
|
67
70
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
68
71
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
69
72
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -103,6 +106,11 @@ python tools/harmonica_reference.py --check
|
|
|
103
106
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
104
107
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
105
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 % |
|
|
106
114
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
107
115
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
108
116
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -130,7 +138,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
130
138
|
|
|
131
139
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
132
140
|
|
|
133
|
-
> 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].
|
|
134
142
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
135
143
|
|
|
136
144
|
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.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"]
|
|
@@ -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"}
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def effective_noise(instrument: float, field_noise: float) -> float:
|
|
@@ -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,10 @@ 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", "gradient") # gradient: magnetic vertical gradient
|
|
23
|
+
METHODS = ("gravity", "magnetic", "gradient", "gpr") # gradient: magnetic vertical gradient; gpr: ground-penetrating radar
|
|
24
24
|
DEFAULT_METHODS = ("gravity", "magnetic")
|
|
25
25
|
|
|
26
26
|
SHAPES: dict[str, str] = {
|
|
@@ -50,6 +50,10 @@ class Target:
|
|
|
50
50
|
host: str = "limestone" # surrounding material
|
|
51
51
|
density_contrast: float | None = None # kg/m^3, overrides the material tables
|
|
52
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)
|
|
53
57
|
|
|
54
58
|
def validate(self) -> None:
|
|
55
59
|
if self.shape not in SHAPES:
|
|
@@ -82,6 +86,8 @@ class Survey:
|
|
|
82
86
|
latitude: float | None = None # for the main field (IGRF-14); needed for magnetics
|
|
83
87
|
longitude: float | None = None
|
|
84
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
|
|
85
91
|
field_total: float | None = None # nT; main field given directly instead of IGRF (all three together)
|
|
86
92
|
field_inclination: float | None = None
|
|
87
93
|
field_declination: float | None = None
|
|
@@ -101,6 +107,7 @@ class MethodResult:
|
|
|
101
107
|
x: list[float] = field(default_factory=list)
|
|
102
108
|
anomaly: list[float] = field(default_factory=list)
|
|
103
109
|
assessment: detect.Assessment | None = None
|
|
110
|
+
details: dict | None = None # method-specific figures (GPR: frequency, velocity, attenuation, ...)
|
|
104
111
|
|
|
105
112
|
|
|
106
113
|
@dataclass
|
|
@@ -129,8 +136,14 @@ class Report:
|
|
|
129
136
|
lines.append(f"- {r.method}: not assessed ({r.reason})")
|
|
130
137
|
continue
|
|
131
138
|
a = r.assessment
|
|
132
|
-
|
|
133
|
-
|
|
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}")
|
|
134
147
|
if a.verdict != "not detectable":
|
|
135
148
|
lines.append(f" stations every {a.station_spacing:g} m or less, profile at least "
|
|
136
149
|
f"{a.profile_length:.0f} m long")
|
|
@@ -273,6 +286,99 @@ def _gradient(t: Target, x, chi, amb, s: Survey, baseline: float):
|
|
|
273
286
|
return _magnetic(t, x, chi, amb, low) - _magnetic(t, x, chi, amb, high)
|
|
274
287
|
|
|
275
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
|
+
|
|
276
382
|
def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
|
|
277
383
|
"""Model ``target`` (a ``Target`` or dict) for each method and assess it for ``survey`` (a ``Survey`` or dict)."""
|
|
278
384
|
t, s = _as(Target, target), _as(Survey, survey)
|
|
@@ -287,6 +393,9 @@ def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
|
|
|
287
393
|
x = _profile(t)
|
|
288
394
|
results = []
|
|
289
395
|
for method in methods:
|
|
396
|
+
if method == "gpr":
|
|
397
|
+
results.append(_gpr(t, s, x))
|
|
398
|
+
continue
|
|
290
399
|
contrast, units, problem = _contrast(t, method)
|
|
291
400
|
if contrast is None:
|
|
292
401
|
results.append(MethodResult(method, False, problem))
|
|
@@ -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
|
|
|
@@ -330,6 +330,40 @@ def cases() -> list[Case]:
|
|
|
330
330
|
magnetics.contact(x, 5.0, 40.0, CHI, POLE),
|
|
331
331
|
_poisson(_dgz_dz(lambda a, b: gravity.vertical_step(x, a, b, 1000.0), 5.0, 40.0), CHI, POLE, 1000.0)))
|
|
332
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
|
+
|
|
333
367
|
# --- independent code ---------------------------------------------------------------------------------------
|
|
334
368
|
out.extend(_harmonica_cases())
|
|
335
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
|
|
@@ -34,9 +34,9 @@ Dynamic: license-file
|
|
|
34
34
|
|
|
35
35
|
# Detectability
|
|
36
36
|
|
|
37
|
+
[](https://doi.org/10.5281/zenodo.23931815)
|
|
37
38
|
[](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
|
|
38
39
|
[](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
|
|
39
|
-
[](https://doi.org/10.5281/zenodo.23931815)
|
|
40
40
|
|
|
41
41
|
**Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
|
|
42
42
|
magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
|
|
@@ -69,7 +69,7 @@ print(report.summary())
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
```text
|
|
72
|
-
Detectability 0.
|
|
72
|
+
Detectability 0.4.0: sphere in limestone
|
|
73
73
|
- gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
|
|
74
74
|
stations every 5 m or less, profile at least 153 m long
|
|
75
75
|
- magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
|
|
@@ -85,10 +85,12 @@ in, and measured contrasts can be given instead.
|
|
|
85
85
|
|
|
86
86
|
Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
|
|
87
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
|
|
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`:
|
|
89
91
|
|
|
90
92
|
```python
|
|
91
|
-
evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
93
|
+
evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
|
|
92
94
|
```
|
|
93
95
|
|
|
94
96
|
## Modules
|
|
@@ -98,6 +100,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
|
|
|
98
100
|
| `survey` | `evaluate()`: target and survey in, report and reproduction script out |
|
|
99
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) |
|
|
100
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) |
|
|
101
104
|
| `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
|
|
102
105
|
| `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
|
|
103
106
|
| `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
|
|
@@ -137,6 +140,11 @@ python tools/harmonica_reference.py --check
|
|
|
137
140
|
| Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
|
|
138
141
|
| Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
|
|
139
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 % |
|
|
140
148
|
| Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
|
|
141
149
|
| Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
|
|
142
150
|
| Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
|
|
@@ -164,7 +172,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
|
|
|
164
172
|
|
|
165
173
|
See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
|
|
166
174
|
|
|
167
|
-
> 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].
|
|
168
176
|
> Zenodo. https://doi.org/10.5281/zenodo.23931815
|
|
169
177
|
|
|
170
178
|
The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
|
|
@@ -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
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|