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.
Files changed (30) hide show
  1. {detectability-0.2.1/src/detectability.egg-info → detectability-0.4.0}/PKG-INFO +28 -8
  2. {detectability-0.2.1 → detectability-0.4.0}/README.md +26 -7
  3. {detectability-0.2.1 → detectability-0.4.0}/pyproject.toml +2 -1
  4. detectability-0.4.0/src/detectability/__init__.py +7 -0
  5. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/detect.py +26 -6
  6. detectability-0.4.0/src/detectability/gpr.py +176 -0
  7. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/survey.py +159 -15
  8. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/validation.py +53 -1
  9. {detectability-0.2.1 → detectability-0.4.0/src/detectability.egg-info}/PKG-INFO +28 -8
  10. {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/SOURCES.txt +2 -0
  11. {detectability-0.2.1 → detectability-0.4.0}/tests/test_detect.py +11 -2
  12. detectability-0.4.0/tests/test_gpr.py +102 -0
  13. {detectability-0.2.1 → detectability-0.4.0}/tests/test_survey.py +40 -0
  14. detectability-0.2.1/src/detectability/__init__.py +0 -7
  15. {detectability-0.2.1 → detectability-0.4.0}/LICENSE +0 -0
  16. {detectability-0.2.1 → detectability-0.4.0}/setup.cfg +0 -0
  17. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/constants.py +0 -0
  18. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/data/harmonica_reference.json +0 -0
  19. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/data/igrf14coeffs.txt +0 -0
  20. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/gravity.py +0 -0
  21. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/igrf.py +0 -0
  22. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/magnetics.py +0 -0
  23. {detectability-0.2.1 → detectability-0.4.0}/src/detectability/rocks.py +0 -0
  24. {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/dependency_links.txt +0 -0
  25. {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/requires.txt +0 -0
  26. {detectability-0.2.1 → detectability-0.4.0}/src/detectability.egg-info/top_level.txt +0 -0
  27. {detectability-0.2.1 → detectability-0.4.0}/tests/test_gravity.py +0 -0
  28. {detectability-0.2.1 → detectability-0.4.0}/tests/test_igrf.py +0 -0
  29. {detectability-0.2.1 → detectability-0.4.0}/tests/test_magnetics.py +0 -0
  30. {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.2.1
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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](https://doi.org/10.5281/zenodo.23931815)
36
38
  [![tests](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml/badge.svg)](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
37
39
  [![licence: BSD-3-Clause](https://img.shields.io/badge/licence-BSD--3--Clause-blue.svg)](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
38
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](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.2.1: sphere in limestone
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 and field noise, signal-to-noise ratio, verdict, station spacing, profile length |
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.2.1) [Computer software].
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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](https://doi.org/10.5281/zenodo.23931815)
3
4
  [![tests](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml/badge.svg)](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
4
5
  [![licence: BSD-3-Clause](https://img.shields.io/badge/licence-BSD--3--Clause-blue.svg)](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
5
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](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.2.1: sphere in limestone
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 and field noise, signal-to-noise ratio, verdict, station spacing, profile length |
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.2.1) [Computer software].
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.2.1"
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 == "magnetic":
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
- lines.append(f"- {r.method}: peak {a.peak:.3g} {a.units}, noise {a.noise:.3g} {a.units}, "
127
- f"S/N {a.snr:.3g}: {a.verdict}")
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 evaluate(target, survey=None, methods=METHODS) -> Report:
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
- inst = s.magnetic_instrument_noise if s.magnetic_instrument_noise is not None else \
299
- detect.MAGNETOMETERS_NT.get(s.magnetometer)
300
- if inst is None:
301
- results.append(MethodResult(method, False, f"unknown magnetometer {s.magnetometer!r}"))
302
- continue
303
- noise = detect.effective_noise(inst, s.magnetic_field_noise)
304
- anomaly = _magnetic(t, x, contrast, amb, s)
305
- res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.magnetometer,
306
- instrument_noise=inst, field_noise=s.magnetic_field_noise, main_field=info)
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 = (s.gravity_instrument_noise if method == "gravity" else s.magnetic_instrument_noise) is not None
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.2.1
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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](https://doi.org/10.5281/zenodo.23931815)
36
38
  [![tests](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml/badge.svg)](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
37
39
  [![licence: BSD-3-Clause](https://img.shields.io/badge/licence-BSD--3--Clause-blue.svg)](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
38
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](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.2.1: sphere in limestone
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 and field noise, signal-to-noise ratio, verdict, station spacing, profile length |
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.2.1) [Computer software].
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 "fluxgate gradiometer" not in detect.MAGNETOMETERS_NT # measures a gradient, not the total field
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