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.
Files changed (29) hide show
  1. {detectability-0.3.0/src/detectability.egg-info → detectability-0.4.0}/PKG-INFO +14 -6
  2. {detectability-0.3.0 → detectability-0.4.0}/README.md +13 -5
  3. {detectability-0.3.0 → detectability-0.4.0}/pyproject.toml +1 -1
  4. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/__init__.py +1 -1
  5. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/detect.py +1 -1
  6. detectability-0.4.0/src/detectability/gpr.py +176 -0
  7. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/survey.py +113 -4
  8. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/validation.py +35 -1
  9. {detectability-0.3.0 → detectability-0.4.0/src/detectability.egg-info}/PKG-INFO +14 -6
  10. {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/SOURCES.txt +2 -0
  11. detectability-0.4.0/tests/test_gpr.py +102 -0
  12. {detectability-0.3.0 → detectability-0.4.0}/LICENSE +0 -0
  13. {detectability-0.3.0 → detectability-0.4.0}/setup.cfg +0 -0
  14. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/constants.py +0 -0
  15. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/data/harmonica_reference.json +0 -0
  16. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/data/igrf14coeffs.txt +0 -0
  17. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/gravity.py +0 -0
  18. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/igrf.py +0 -0
  19. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/magnetics.py +0 -0
  20. {detectability-0.3.0 → detectability-0.4.0}/src/detectability/rocks.py +0 -0
  21. {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/dependency_links.txt +0 -0
  22. {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/requires.txt +0 -0
  23. {detectability-0.3.0 → detectability-0.4.0}/src/detectability.egg-info/top_level.txt +0 -0
  24. {detectability-0.3.0 → detectability-0.4.0}/tests/test_detect.py +0 -0
  25. {detectability-0.3.0 → detectability-0.4.0}/tests/test_gravity.py +0 -0
  26. {detectability-0.3.0 → detectability-0.4.0}/tests/test_igrf.py +0 -0
  27. {detectability-0.3.0 → detectability-0.4.0}/tests/test_magnetics.py +0 -0
  28. {detectability-0.3.0 → detectability-0.4.0}/tests/test_survey.py +0 -0
  29. {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.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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](https://doi.org/10.5281/zenodo.23931815)
37
38
  [![tests](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml/badge.svg)](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
38
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)
39
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](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.3.0: sphere in limestone
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. The defaults are `gravity` and `magnetic`:
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.3.0) [Computer software].
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
+ [![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.3.0: 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
@@ -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. The defaults are `gravity` and `magnetic`:
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.3.0) [Computer software].
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.3.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"]
@@ -1,6 +1,6 @@
1
1
  """Detectability: pre-survey detectability, modelled and reproducible."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  from .survey import DEFAULT_METHODS, METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
6
6
 
@@ -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 (two-sensor gradiometer)
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
- lines.append(f"- {r.method}: peak {a.peak:.3g} {a.units}, noise {a.noise:.3g} {a.units}, "
133
- 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}")
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.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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](https://doi.org/10.5281/zenodo.23931815)
37
38
  [![tests](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml/badge.svg)](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
38
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)
39
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](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.3.0: sphere in limestone
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. The defaults are `gravity` and `magnetic`:
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.3.0) [Computer software].
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