detectability 0.4.0__tar.gz → 0.6.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 (36) hide show
  1. {detectability-0.4.0/src/detectability.egg-info → detectability-0.6.0}/PKG-INFO +36 -5
  2. {detectability-0.4.0 → detectability-0.6.0}/README.md +33 -4
  3. {detectability-0.4.0 → detectability-0.6.0}/pyproject.toml +3 -1
  4. detectability-0.6.0/src/detectability/__init__.py +9 -0
  5. detectability-0.6.0/src/detectability/data/ert_reference.json +40 -0
  6. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/detect.py +1 -1
  7. detectability-0.6.0/src/detectability/ert.py +272 -0
  8. detectability-0.6.0/src/detectability/limits.py +121 -0
  9. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/survey.py +45 -4
  10. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/validation.py +31 -0
  11. {detectability-0.4.0 → detectability-0.6.0/src/detectability.egg-info}/PKG-INFO +36 -5
  12. {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/SOURCES.txt +6 -0
  13. {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/requires.txt +3 -0
  14. detectability-0.6.0/tests/test_ert.py +61 -0
  15. detectability-0.6.0/tests/test_ert_pygimli.py +19 -0
  16. detectability-0.6.0/tests/test_limits.py +93 -0
  17. detectability-0.4.0/src/detectability/__init__.py +0 -7
  18. {detectability-0.4.0 → detectability-0.6.0}/LICENSE +0 -0
  19. {detectability-0.4.0 → detectability-0.6.0}/setup.cfg +0 -0
  20. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/constants.py +0 -0
  21. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/data/harmonica_reference.json +0 -0
  22. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/data/igrf14coeffs.txt +0 -0
  23. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/gpr.py +0 -0
  24. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/gravity.py +0 -0
  25. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/igrf.py +0 -0
  26. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/magnetics.py +0 -0
  27. {detectability-0.4.0 → detectability-0.6.0}/src/detectability/rocks.py +0 -0
  28. {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/dependency_links.txt +0 -0
  29. {detectability-0.4.0 → detectability-0.6.0}/src/detectability.egg-info/top_level.txt +0 -0
  30. {detectability-0.4.0 → detectability-0.6.0}/tests/test_detect.py +0 -0
  31. {detectability-0.4.0 → detectability-0.6.0}/tests/test_gpr.py +0 -0
  32. {detectability-0.4.0 → detectability-0.6.0}/tests/test_gravity.py +0 -0
  33. {detectability-0.4.0 → detectability-0.6.0}/tests/test_igrf.py +0 -0
  34. {detectability-0.4.0 → detectability-0.6.0}/tests/test_magnetics.py +0 -0
  35. {detectability-0.4.0 → detectability-0.6.0}/tests/test_survey.py +0 -0
  36. {detectability-0.4.0 → detectability-0.6.0}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectability
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets.
5
5
  Author: Mükremin Nergiz
6
6
  License-Expression: BSD-3-Clause
@@ -30,6 +30,8 @@ Provides-Extra: test
30
30
  Requires-Dist: pytest>=7; extra == "test"
31
31
  Provides-Extra: validation
32
32
  Requires-Dist: harmonica==0.7.0; extra == "validation"
33
+ Provides-Extra: ert
34
+ Requires-Dist: pygimli>=1.5; extra == "ert"
33
35
  Dynamic: license-file
34
36
 
35
37
  # Detectability
@@ -69,7 +71,7 @@ print(report.summary())
69
71
  ```
70
72
 
71
73
  ```text
72
- Detectability 0.4.0: sphere in limestone
74
+ Detectability 0.6.0: sphere in limestone
73
75
  - gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
74
76
  stations every 5 m or less, profile at least 153 m long
75
77
  - magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
@@ -79,6 +81,29 @@ Recommended method: gravity
79
81
  `report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
80
82
  including the modelled profiles and the assumptions.
81
83
 
84
+ ### How deep before a method loses it?
85
+
86
+ `depth_limit()` asks the inverse question — the one a survey plan turns on. Leave `depth_top` out: it is what the
87
+ search solves for.
88
+
89
+ ```python
90
+ from detectability import depth_limit
91
+
92
+ for method in ("gravity", "magnetic", "gpr"):
93
+ print(depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
94
+ {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method=method))
95
+ ```
96
+
97
+ ```text
98
+ gravity: detectable (S/N >= 5) down to 25.7 m
99
+ magnetic: never reaches S/N 5; S/N 2.88 even at 0.5 m
100
+ gpr: detectable (S/N >= 5) down to 25.2 m
101
+ ```
102
+
103
+ Pass `threshold=detect.MARGINAL` for the lower threshold, and `search=(shallow, deep)` to bound the sweep.
104
+ Sweep the target size and you have a detection-limit chart; the website draws one at
105
+ [/limits](https://detectability.mukreminnergiz.com/limits).
106
+
82
107
  Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
83
108
  geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
84
109
  in, and measured contrasts can be given instead.
@@ -87,10 +112,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
87
112
  (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
88
113
  FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
89
114
  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`:
115
+ recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
116
+ under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
117
+ apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
118
+ [pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
91
119
 
92
120
  ```python
93
- evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
121
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
94
122
  ```
95
123
 
96
124
  ## Modules
@@ -101,6 +129,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
101
129
  | `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) |
102
130
  | `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
131
  | `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) |
132
+ | `ert` | electrical resistivity tomography with pyGIMLi (optional): electrode layout, 2D finite-element forward model, inversion and recovery check; typical resistivities (Loke 1999, after Keller & Frischknecht 1966) |
104
133
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
105
134
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
106
135
  | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
@@ -145,6 +174,8 @@ python tools/harmonica_reference.py --check
145
174
  | Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
146
175
  | Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
147
176
  | Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
177
+ | Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
178
+ | Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
148
179
  | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
149
180
  | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
150
181
  | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
@@ -172,7 +203,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
172
203
 
173
204
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
174
205
 
175
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
206
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
176
207
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
177
208
 
178
209
  The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
@@ -35,7 +35,7 @@ print(report.summary())
35
35
  ```
36
36
 
37
37
  ```text
38
- Detectability 0.4.0: sphere in limestone
38
+ Detectability 0.6.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
@@ -45,6 +45,29 @@ Recommended method: gravity
45
45
  `report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
46
46
  including the modelled profiles and the assumptions.
47
47
 
48
+ ### How deep before a method loses it?
49
+
50
+ `depth_limit()` asks the inverse question — the one a survey plan turns on. Leave `depth_top` out: it is what the
51
+ search solves for.
52
+
53
+ ```python
54
+ from detectability import depth_limit
55
+
56
+ for method in ("gravity", "magnetic", "gpr"):
57
+ print(depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
58
+ {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method=method))
59
+ ```
60
+
61
+ ```text
62
+ gravity: detectable (S/N >= 5) down to 25.7 m
63
+ magnetic: never reaches S/N 5; S/N 2.88 even at 0.5 m
64
+ gpr: detectable (S/N >= 5) down to 25.2 m
65
+ ```
66
+
67
+ Pass `threshold=detect.MARGINAL` for the lower threshold, and `search=(shallow, deep)` to bound the sweep.
68
+ Sweep the target size and you have a detection-limit chart; the website draws one at
69
+ [/limits](https://detectability.mukreminnergiz.com/limits).
70
+
48
71
  Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
49
72
  geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
50
73
  in, and measured contrasts can be given instead.
@@ -53,10 +76,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
53
76
  (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
54
77
  FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
55
78
  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`:
79
+ recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
80
+ under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
81
+ apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
82
+ [pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
57
83
 
58
84
  ```python
59
- evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
85
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
60
86
  ```
61
87
 
62
88
  ## Modules
@@ -67,6 +93,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
67
93
  | `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) |
68
94
  | `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
95
  | `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) |
96
+ | `ert` | electrical resistivity tomography with pyGIMLi (optional): electrode layout, 2D finite-element forward model, inversion and recovery check; typical resistivities (Loke 1999, after Keller & Frischknecht 1966) |
70
97
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
71
98
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
72
99
  | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
@@ -111,6 +138,8 @@ python tools/harmonica_reference.py --check
111
138
  | Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
112
139
  | Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
113
140
  | Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
141
+ | Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
142
+ | Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
114
143
  | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
115
144
  | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
116
145
  | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
@@ -138,7 +167,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
138
167
 
139
168
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
140
169
 
141
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
170
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
142
171
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
143
172
 
144
173
  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.4.0"
7
+ version = "0.6.0"
8
8
  description = "Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets."
9
9
  readme = "README.md"
10
10
  keywords = ["geophysics", "gravity", "magnetics", "survey design", "near-surface", "forward modelling", "IGRF"]
@@ -38,6 +38,8 @@ Citation = "https://doi.org/10.5281/zenodo.23931815"
38
38
  test = ["pytest>=7"]
39
39
  # Recomputes the "Independent code" reference values (tools/harmonica_reference.py); not needed to use the library.
40
40
  validation = ["harmonica==0.7.0"]
41
+ # Electrical resistivity tomography (method "ert"): finite elements and inversion with pyGIMLi.
42
+ ert = ["pygimli>=1.5"]
41
43
 
42
44
  [tool.setuptools.packages.find]
43
45
  where = ["src"]
@@ -0,0 +1,9 @@
1
+ """Detectability: pre-survey detectability, modelled and reproducible."""
2
+
3
+ __version__ = "0.6.0"
4
+
5
+ from .limits import Limit, depth_limit # noqa: E402
6
+ from .survey import DEFAULT_METHODS, METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
7
+
8
+ __all__ = ["evaluate", "depth_limit", "Limit", "Target", "Survey", "Report",
9
+ "SHAPES", "METHODS", "DEFAULT_METHODS", "__version__"]
@@ -0,0 +1,40 @@
1
+ {
2
+ "pygimli": "1.6.1",
3
+ "doi": "10.1016/j.cageo.2017.07.011",
4
+ "halfspace_error_percent": 0.29701507707668995,
5
+ "wenner_two_layer": {
6
+ "rho1": 100.0,
7
+ "rho2": 10.0,
8
+ "h": 5.0,
9
+ "spacing": [
10
+ 1.0,
11
+ 2.0,
12
+ 3.0,
13
+ 5.0,
14
+ 8.0,
15
+ 12.0,
16
+ 16.0,
17
+ 20.0
18
+ ],
19
+ "model": [
20
+ 99.61000261515714,
21
+ 96.89634932280532,
22
+ 91.18162883033095,
23
+ 73.36750131129314,
24
+ 46.680818102896644,
25
+ 25.39363222503698,
26
+ 16.284202471814222,
27
+ 12.780339615225937
28
+ ],
29
+ "analytic": [
30
+ 99.56748456281315,
31
+ 96.90460006182809,
32
+ 91.1609264135159,
33
+ 73.39044630419616,
34
+ 46.53753517471967,
35
+ 25.330265325884227,
36
+ 16.376777534327324,
37
+ 12.860338899308598
38
+ ]
39
+ }
40
+ }
@@ -76,7 +76,7 @@ DEFAULT_FIELD_NOISE_NT = 2.0 # quiet rural site after diurnal correction; ur
76
76
  # by less than about 0.5 nT over 1 m); change it for the site.
77
77
  DEFAULT_FIELD_NOISE_GRADIENT_NT = 0.5
78
78
 
79
- UNITS = {"gravity": "µGal", "magnetic": "nT", "gradient": "nT", "gpr": "× detection limit"}
79
+ UNITS = {"gravity": "µGal", "magnetic": "nT", "gradient": "nT", "gpr": "× detection limit", "ert": "%"}
80
80
 
81
81
 
82
82
  def effective_noise(instrument: float, field_noise: float) -> float:
@@ -0,0 +1,272 @@
1
+ """Electrical resistivity tomography (ERT): 2D forward model of a target under a line of electrodes, the change it
2
+ makes to the apparent resistivities, and whether an inversion of noisy synthetic data recovers it.
3
+
4
+ The finite-element modelling and the inversion use pyGIMLi (Rücker, Günther & Wagner 2017, Computers & Geosciences
5
+ 109, 106-123), an optional dependency: ``pip install "detectability[ert]"``. Without it the method is reported as not
6
+ available. A service can run it instead: if pyGIMLi is missing and ``DETECTABILITY_ERT_URL`` is set, the request is
7
+ sent there (the website does this; its ERT runs on a separate machine).
8
+
9
+ What is computed, and the conventions:
10
+ - Electrodes at equal spacing along a straight line centred over the target; arrays: dipole-dipole, Wenner,
11
+ Schlumberger or multiple gradient.
12
+ - Apparent resistivities with and without the target on the same mesh; their ratio removes the mesh error. The data
13
+ anomaly is 100 (rho_a,target / rho_a,host - 1) %, and S/N is its largest absolute value over the data noise (%), with
14
+ the same thresholds as the other methods.
15
+ - Recovery: the noisy data (the given noise level) are inverted (smoothness-constrained Gauss-Newton, pyGIMLi's
16
+ ERTManager, at most 6 iterations). The recovered contrast is the median model value inside the target over the median
17
+ outside. The target counts as recovered when that contrast has the right sign and reaches 20 % of the true contrast
18
+ in log scale or a factor of 1.5, whichever is smaller; otherwise a detectable verdict is lowered to marginal.
19
+ - 2D: the target is infinitely long across the profile. A sphere is modelled as a horizontal cylinder of the same
20
+ diameter, which overstates its effect; the assumptions say so.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import time
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+
33
+ # Resistivity ranges (ohm m), from Loke (1999), "Electrical imaging surveys for environmental and engineering
34
+ # studies", Table 1, after Keller & Frischknecht (1966) and Daniels & Alberty (1966). "Typical" is the geometric
35
+ # middle of the range. Air (a void) is effectively an insulator; it is modelled as 10^6 ohm m, beyond which the
36
+ # anomaly no longer changes. Metal: iron, 9.074e-8 ohm m.
37
+ RESISTIVITY: dict[str, tuple[float, float, float]] = {
38
+ "air": (1e6, 1e6, 1e6),
39
+ "granite": (7.07e4, 5e3, 1e6),
40
+ "basalt": (3.16e4, 1e3, 1e6),
41
+ "slate": (1.55e5, 6e2, 4e7),
42
+ "marble": (1.58e5, 1e2, 2.5e8),
43
+ "quartzite": (1.41e5, 1e2, 2e8),
44
+ "sandstone": (1.79e2, 8.0, 4e3),
45
+ "shale": (2.0e2, 20.0, 2e3),
46
+ "limestone": (1.41e2, 50.0, 4e2),
47
+ "clay": (10.0, 1.0, 100.0),
48
+ "alluvium": (89.4, 10.0, 800.0),
49
+ "water": (31.6, 10.0, 100.0),
50
+ "fresh water": (31.6, 10.0, 100.0),
51
+ "sea water": (0.2, 0.2, 0.2),
52
+ "metal": (9.074e-8, 9.074e-8, 9.074e-8),
53
+ }
54
+
55
+ ARRAYS = {"dipole-dipole": "dd", "wenner": "wa", "schlumberger": "slm", "gradient": "gr"}
56
+ DEFAULT_ARRAY = "dipole-dipole"
57
+ DEFAULT_ELECTRODES = 48
58
+ DEFAULT_NOISE_PERCENT = 3.0
59
+ RECOVERY_THRESHOLD = 0.2
60
+ RECOVERED_FACTOR = 1.5
61
+ MIN_RESISTIVITY = 1e-3 # metal is modelled as 10^-3 ohm m (a perfect conductor for the survey; avoids a singular system)
62
+ DEFAULT_SERVICE_TIMEOUT = 150
63
+
64
+
65
+ def resistivity(name: str) -> float:
66
+ try:
67
+ return RESISTIVITY[name][0]
68
+ except KeyError:
69
+ raise KeyError(f"no resistivity for {name!r}; known: {', '.join(sorted(RESISTIVITY))}") from None
70
+
71
+
72
+ def available() -> bool:
73
+ try:
74
+ import pygimli # noqa: F401
75
+ return True
76
+ except ImportError:
77
+ return False
78
+
79
+
80
+ def _nice_down(v: float) -> float:
81
+ e = np.floor(np.log10(v))
82
+ for m in (5.0, 2.0, 1.0):
83
+ if m * 10**e <= v:
84
+ return float(m * 10**e)
85
+ return float(5 * 10 ** (e - 1))
86
+
87
+
88
+ def default_spacing(depth_needed: float, electrodes: int) -> float:
89
+ """Electrode spacing whose spread (electrodes - 1 spacings) is about six times the depth that must be seen."""
90
+ return max(0.1, _nice_down(6.0 * depth_needed / (electrodes - 1)))
91
+
92
+
93
+ @dataclass
94
+ class Geometry:
95
+ polygon: list # target outline (x, z), z negative downwards, in the profile frame centred on the target
96
+ depth_needed: float # m: the bottom of the target (or of its useful part) that the survey must reach
97
+ area: float # m^2 of the 2D outline
98
+
99
+
100
+ def geometry(t) -> Geometry:
101
+ """2D outline of a ``survey.Target``."""
102
+ r = (t.size or 0) / 2
103
+ if t.shape in ("sphere", "horizontal_cylinder"):
104
+ zc = t.depth_top + r
105
+ ang = np.linspace(0, 2 * np.pi, 48, endpoint=False)
106
+ return Geometry([(r * np.cos(a), -zc + r * np.sin(a)) for a in ang], zc + r, np.pi * r * r)
107
+ if t.shape in ("vertical_cylinder", "prism"):
108
+ w = t.size / 2
109
+ return Geometry([(-w, -t.depth_top), (w, -t.depth_top), (w, -t.depth_bottom), (-w, -t.depth_bottom)],
110
+ t.depth_bottom, 2 * w * (t.depth_bottom - t.depth_top))
111
+ if t.shape == "dyke":
112
+ h = t.depth_bottom - t.depth_top
113
+ dx = h / np.tan(np.radians(t.dip)) if abs(t.dip - 90) > 1e-9 else 0.0
114
+ w = t.size / 2
115
+ return Geometry([(-w, -t.depth_top), (w, -t.depth_top), (w + dx, -t.depth_bottom), (-w + dx, -t.depth_bottom)],
116
+ t.depth_bottom, t.size * h)
117
+ if t.shape == "contact":
118
+ return Geometry([], t.depth_bottom, float("inf"))
119
+ raise ValueError(f"unknown shape {t.shape!r}")
120
+
121
+
122
+ def run(t, s) -> dict:
123
+ """Model, assess and invert. Returns a plain dict (MethodResult fields) so a service can send it as JSON."""
124
+ import pygimli as pg
125
+ import pygimli.meshtools as mt
126
+ from pygimli.physics import ert as pgert
127
+
128
+ from . import detect
129
+
130
+ pg.setThreadCount(int(os.environ.get("DETECTABILITY_ERT_THREADS", "2")))
131
+ started = time.time()
132
+ rho_h = t.host_resistivity if t.host_resistivity is not None else resistivity(t.host)
133
+ rho_t = t.resistivity if t.resistivity is not None else resistivity(t.material)
134
+ rho_t = max(rho_t, MIN_RESISTIVITY)
135
+ if rho_h <= 0:
136
+ raise ValueError("host resistivity must be positive")
137
+ array = s.ert_array or DEFAULT_ARRAY
138
+ if array not in ARRAYS:
139
+ raise ValueError(f"unknown ERT array {array!r}; choose from {', '.join(ARRAYS)}")
140
+ n = int(s.ert_electrodes or DEFAULT_ELECTRODES)
141
+ if not 8 <= n <= 96:
142
+ raise ValueError("ert_electrodes must be between 8 and 96")
143
+ noise = s.ert_noise if s.ert_noise is not None else DEFAULT_NOISE_PERCENT
144
+ if not 0.1 <= noise <= 50:
145
+ raise ValueError("ert_noise must be between 0.1 and 50 %")
146
+ g = geometry(t)
147
+ a = s.ert_spacing or default_spacing(g.depth_needed, n)
148
+ if a <= 0:
149
+ raise ValueError("ert_spacing must be positive")
150
+ L = a * (n - 1)
151
+ x0 = -L / 2
152
+ elecs = np.linspace(x0, x0 + L, n)
153
+ scheme = pgert.createData(elecs=elecs, schemeName=ARRAYS[array])
154
+
155
+ pad = max(L, 4 * g.depth_needed)
156
+ depth = max(L / 2, 2.5 * g.depth_needed)
157
+ world = mt.createWorld(start=[x0 - pad, 0], end=[x0 + L + pad, -depth - pad], worldMarker=True)
158
+ if t.shape == "contact":
159
+ body = mt.createPolygon([(0, -t.depth_top), (x0 + L + pad, -t.depth_top), (x0 + L + pad, -t.depth_bottom),
160
+ (0, -t.depth_bottom)], isClosed=True, marker=2)
161
+ else:
162
+ body = mt.createPolygon(g.polygon, isClosed=True, marker=2, area=max((a / 4) ** 2, g.area / 200))
163
+ geom = world + body
164
+ for p in scheme.sensors():
165
+ geom.createNode(p)
166
+ geom.createNode(p - [0, a / 10])
167
+ mesh = mt.createMesh(geom, quality=33.5)
168
+
169
+ with_target = pgert.simulate(mesh, scheme=scheme, res=[[1, rho_h], [2, rho_t]], noiseLevel=0, verbose=False)
170
+ host_only = pgert.simulate(mesh, scheme=scheme, res=[[1, rho_h], [2, rho_h]], noiseLevel=0, verbose=False)
171
+ ra_t, ra_h = np.array(with_target["rhoa"]), np.array(host_only["rhoa"])
172
+ anomaly = 100.0 * (ra_t / ra_h - 1.0)
173
+
174
+ # Profile for the assessment: at each data midpoint, the largest change over the levels.
175
+ mid = np.array([(elecs[int(with_target["a"][i])] + elecs[int(with_target["b"][i])] +
176
+ elecs[int(with_target["m"][i])] + elecs[int(with_target["n"][i])]) / 4
177
+ for i in range(with_target.size())])
178
+ xs = np.unique(np.round(mid, 6))
179
+ prof = np.array([anomaly[np.isclose(mid, xv)][np.argmax(np.abs(anomaly[np.isclose(mid, xv)]))] for xv in xs])
180
+ assessment = detect.assess(xs, prof, noise, depth=t.depth_top, method="ert")
181
+ assessment.station_spacing = float(a)
182
+ assessment.profile_length = float(L)
183
+
184
+ details = {"array": array, "electrodes": n, "spacing_m": float(a), "spread_m": float(L), "data": int(scheme.size()),
185
+ "host_resistivity": float(rho_h), "target_resistivity": float(rho_t), "noise_percent": float(noise),
186
+ "max_anomaly_percent": float(np.max(np.abs(anomaly))), "inverted": False}
187
+
188
+ if s.ert_invert and t.shape != "contact":
189
+ rng = np.random.default_rng(1337)
190
+ data = pg.DataContainerERT(with_target)
191
+ data["rhoa"] = ra_t * (1 + rng.normal(0, noise / 100, len(ra_t)))
192
+ data["err"] = pg.Vector(len(ra_t), noise / 100)
193
+ mgr = pgert.ERTManager(data, verbose=False)
194
+ mgr.invert(lam=20, verbose=False, paraDepth=min(depth, max(1.5 * g.depth_needed, L / 5)), quality=33.6,
195
+ paraMaxCellSize=max(a * a, g.area / 50), paraDX=0.5, maxIter=6)
196
+ cells = np.array(mgr.paraDomain.cellCenters())[:, :2]
197
+ model = np.array(mgr.model)
198
+ inside = np.array([_inside(p, g.polygon) for p in cells])
199
+ if inside.sum() >= 3:
200
+ rec_t, rec_bg = float(np.median(model[inside])), float(np.median(model[~inside]))
201
+ recovery = float(np.log(rec_t / rec_bg) / np.log(rho_t / rho_h)) if rho_t != rho_h else 0.0
202
+ else:
203
+ rec_t, rec_bg, recovery = None, float(np.median(model)), 0.0
204
+ # Recovered: the inverted contrast points the right way and reaches 20 % of the true contrast (log scale) or a
205
+ # factor of 1.5, whichever is smaller (a void's true contrast is huge; no inversion of surface data reaches it).
206
+ need = min(RECOVERY_THRESHOLD * abs(np.log(rho_t / rho_h)), np.log(RECOVERED_FACTOR))
207
+ recovered = bool(rec_t is not None and rho_t != rho_h and np.sign(np.log(rec_t / rec_bg)) == np.sign(np.log(rho_t / rho_h))
208
+ and abs(np.log(rec_t / rec_bg)) >= need)
209
+ details.update(inverted=True, recovered_target=rec_t, recovered_background=rec_bg, recovery=recovery,
210
+ recovered=recovered, chi2=float(mgr.inv.chi2()), inversion_cells=int(len(model)),
211
+ section={"x": cells[:, 0].round(3).tolist(), "z": cells[:, 1].round(3).tolist(),
212
+ "rho": np.round(model, 4).tolist()})
213
+ if not recovered and assessment.verdict != "not detectable":
214
+ assessment.verdict = "marginal"
215
+ details["seconds"] = round(time.time() - started, 1)
216
+
217
+ unit = "%"
218
+ asm = [
219
+ f"Signal-to-noise thresholds: >= {detect.CLEAR:g} clearly detectable, {detect.MARGINAL:g}-{detect.CLEAR:g} "
220
+ f"marginal, < {detect.MARGINAL:g} not detectable; S/N is the largest change in apparent resistivity over the "
221
+ f"data noise ({noise:g} %).",
222
+ f"{n} electrodes every {a:g} m ({L:g} m spread), {array} array, {scheme.size()} measurements.",
223
+ f"Host {rho_h:.3g} Ωm, target {rho_t:.3g} Ωm" + ("" if t.resistivity is not None or t.host_resistivity is not None
224
+ else " (typical values; resistivity ranges over orders of magnitude, measure on site)") + ".",
225
+ "2D model: the target is infinitely long across the profile; homogeneous host, flat ground, electrodes on the "
226
+ "surface. Finite-element forward model (pyGIMLi); the target/host ratio cancels the mesh error.",
227
+ ]
228
+ if t.shape == "sphere":
229
+ asm.append("A sphere is modelled as a horizontal cylinder of the same diameter: a real 3D sphere gives a "
230
+ "smaller anomaly than shown.")
231
+ if t.shape in ("prism", "vertical_cylinder"):
232
+ asm.append("A 3D block or shaft is modelled by its 2D cross-section; a short target gives a smaller anomaly.")
233
+ if details["inverted"]:
234
+ rt = details["recovered_target"]
235
+ asm.append(f"Inversion of the noisy data (smoothness constraint, λ 20, χ² {details['chi2']:.2g}): recovered "
236
+ f"{'%.3g' % rt + ' Ωm in the target' if rt else 'no cells inside the target'} against "
237
+ f"{details['recovered_background']:.3g} Ωm around it ({100 * details['recovery']:.0f} % of the true contrast "
238
+ f"in log scale): " + ("recovered." if details["recovered"] else "not recovered (needs 20 % of the "
239
+ "contrast in log scale or a factor of 1.5)."))
240
+ elif t.shape == "contact":
241
+ asm.append("Inversion not run for a contact (no closed target).")
242
+ if a > (t.size or a):
243
+ asm.append(f"Electrode spacing ({a:g} m) is larger than the target ({t.size:g} m): its shape is not resolved.")
244
+ if t.shape in ("horizontal_cylinder", "dyke", "contact"):
245
+ asm.append("2D target: infinitely long along strike, at right angles to the profile.")
246
+ assessment.assumptions = asm
247
+ assessment.units = unit
248
+ return {"method": "ert", "available": True, "contrast": float(rho_t / rho_h), "contrast_units": "resistivity ratio",
249
+ "instrument": f"{n} electrodes, {array}", "instrument_noise": float(noise), "field_noise": None,
250
+ "x": xs.round(4).tolist(), "anomaly": prof.tolist(), "assessment": assessment.to_dict(), "details": details}
251
+
252
+
253
+ def _inside(p, poly) -> bool:
254
+ x, z = p
255
+ inside = False
256
+ j = len(poly) - 1
257
+ for i in range(len(poly)):
258
+ xi, zi = poly[i]
259
+ xj, zj = poly[j]
260
+ if (zi > z) != (zj > z) and x < (xj - xi) * (z - zi) / (zj - zi) + xi:
261
+ inside = not inside
262
+ j = i
263
+ return inside
264
+
265
+
266
+ def remote(target: dict, survey: dict, url: str) -> dict:
267
+ """Ask an ERT service (another machine with pyGIMLi) for the result."""
268
+ req = urllib.request.Request(url, data=json.dumps({"target": target, "survey": survey}).encode(),
269
+ headers={"Content-Type": "application/json"})
270
+ timeout = float(os.environ.get("DETECTABILITY_ERT_TIMEOUT", DEFAULT_SERVICE_TIMEOUT))
271
+ with urllib.request.urlopen(req, timeout=timeout) as r:
272
+ return json.loads(r.read())
@@ -0,0 +1,121 @@
1
+ """How deep a method still sees a target: the depth limit, solved for instead of assumed.
2
+
3
+ ``evaluate`` answers "can this method see the target at this depth?". The inverse question is the one that shapes a
4
+ survey plan: *how deep can the target be before the method loses it?*
5
+
6
+ >>> from detectability import depth_limit
7
+ >>> limit = depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
8
+ ... {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method="gravity")
9
+ >>> print(limit)
10
+
11
+ ``depth_top`` on the target is ignored, and may be left out entirely: it is what the search solves for. If the shape
12
+ also needs ``depth_bottom``, the body keeps its thickness as it moves down.
13
+
14
+ The search makes no assumption that signal-to-noise falls smoothly with depth, because it does not always: the GPR
15
+ antenna frequency, when left to the default, is chosen from the target depth, so the response can step. A coarse
16
+ geometric scan brackets the **shallowest** crossing of the threshold and a bisection refines it. The shallowest
17
+ crossing is the honest answer — beyond it the method can no longer be relied on, whatever happens deeper.
18
+
19
+ Each limit costs ``steps`` plus about twenty forward models. That is milliseconds for gravity, magnetics and GPR.
20
+ For ERT, where a single forward model inverts a mesh and takes tens of seconds, a limit takes tens of minutes; pass a
21
+ narrow ``search`` range and a small ``steps`` if you ask for one at all.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, replace
27
+
28
+ from . import detect
29
+ from .survey import NEEDS_BOTTOM, Survey, Target, _as, evaluate
30
+
31
+ __all__ = ["Limit", "depth_limit"]
32
+
33
+ SEARCH = (0.5, 2000.0) # m below ground: shallow enough for utilities, deep enough for an ore body
34
+ STEPS = 24 # coarse scan points, geometric over the search range
35
+ REFINE = 20 # bisections; takes a 24-point scan of 0.5-2000 m below 0.1 % in depth
36
+
37
+
38
+ @dataclass
39
+ class Limit:
40
+ """The greatest depth at which ``method`` still reaches ``threshold`` for a given target."""
41
+
42
+ method: str
43
+ threshold: float
44
+ depth: float | None # m to the top of the target; None when the method never reaches the threshold
45
+ snr_shallowest: float | None # signal-to-noise at the shallowest depth searched, for context
46
+ search: tuple[float, float]
47
+ beyond_search: bool = False # still detected at the deepest depth searched: the limit is at least that
48
+ available: bool = True
49
+ reason: str = ""
50
+
51
+ def __str__(self) -> str:
52
+ if not self.available:
53
+ return f"{self.method}: not assessed ({self.reason})"
54
+ if self.depth is None:
55
+ return (f"{self.method}: never reaches S/N {self.threshold:g}; "
56
+ f"S/N {self.snr_shallowest:.3g} even at {self.search[0]:g} m")
57
+ at_least = "at least " if self.beyond_search else ""
58
+ return f"{self.method}: detectable (S/N >= {self.threshold:g}) down to {at_least}{self.depth:.3g} m"
59
+
60
+
61
+ def _at(target: Target, depth: float) -> Target:
62
+ """The same body, moved so its top sits at ``depth``; thickness kept where the shape needs a bottom."""
63
+ moved = replace(target, depth_top=depth)
64
+ if target.shape in NEEDS_BOTTOM:
65
+ thickness = (target.depth_bottom - target.depth_top) if target.depth_bottom else max(target.size or 1.0, 1.0)
66
+ moved.depth_bottom = depth + thickness
67
+ return moved
68
+
69
+
70
+ def depth_limit(target, survey=None, method: str = "gravity", threshold: float = detect.CLEAR,
71
+ search: tuple[float, float] = SEARCH, steps: int = STEPS, refine: int = REFINE) -> Limit:
72
+ """Greatest depth at which ``method`` still reaches ``threshold`` for ``target`` under ``survey``.
73
+
74
+ ``threshold`` defaults to the clearly-detectable signal-to-noise (5); pass ``detect.MARGINAL`` for the lower one.
75
+ The result's ``depth`` is None when the method never reaches the threshold, even at the shallowest depth searched.
76
+ """
77
+ shallow, deep = float(search[0]), float(search[1])
78
+ if not 0 < shallow < deep:
79
+ raise ValueError(f"search must be (shallow, deep) with 0 < shallow < deep; got {search!r}")
80
+ if steps < 2:
81
+ raise ValueError("steps must be at least 2")
82
+
83
+ if isinstance(target, dict) and "depth_top" not in target:
84
+ target = {**target, "depth_top": shallow} # it is what the search solves for; any value will do
85
+ t, s = _as(Target, target), _as(Survey, survey)
86
+ t.validate()
87
+
88
+ def snr(depth: float) -> tuple[float, bool, str]:
89
+ result = evaluate(_at(t, depth), s, methods=[method]).results[0]
90
+ if not result.available:
91
+ return 0.0, False, result.reason
92
+ return result.assessment.snr, True, ""
93
+
94
+ first, ok, why = snr(shallow)
95
+ if not ok:
96
+ return Limit(method, threshold, None, None, (shallow, deep), available=False, reason=why)
97
+ if first < threshold:
98
+ return Limit(method, threshold, None, first, (shallow, deep))
99
+
100
+ # Coarse scan for the shallowest crossing, then bisect inside that bracket.
101
+ ratio = (deep / shallow) ** (1 / (steps - 1))
102
+ lo, lo_depth = shallow, shallow
103
+ for i in range(1, steps):
104
+ probe = shallow * ratio ** i
105
+ value, ok, _ = snr(probe)
106
+ if not ok or value < threshold:
107
+ hi = probe
108
+ break
109
+ lo_depth = probe
110
+ else:
111
+ return Limit(method, threshold, deep, first, (shallow, deep), beyond_search=True)
112
+
113
+ lo = lo_depth
114
+ for _ in range(refine):
115
+ mid = (lo * hi) ** 0.5
116
+ value, ok, _ = snr(mid)
117
+ if ok and value >= threshold:
118
+ lo = mid
119
+ else:
120
+ hi = mid
121
+ return Limit(method, threshold, (lo * hi) ** 0.5, first, (shallow, deep))
@@ -13,14 +13,16 @@ from __future__ import annotations
13
13
 
14
14
  import datetime as _dt
15
15
  import json
16
+ import re
16
17
  from dataclasses import asdict, dataclass, field, fields, replace
17
18
 
18
19
  import numpy as np
19
20
 
20
- from . import __version__, detect, gpr, gravity, igrf, magnetics, rocks
21
+ from . import __version__, detect, ert, gpr, gravity, igrf, magnetics, rocks
21
22
  from .constants import SI_TO_UGAL
22
23
 
23
- METHODS = ("gravity", "magnetic", "gradient", "gpr") # gradient: magnetic vertical gradient; gpr: ground-penetrating radar
24
+ METHODS = ("gravity", "magnetic", "gradient", "gpr", "ert") # gradient: magnetic vertical gradient; gpr: ground-penetrating
25
+ # radar; ert: electrical resistivity tomography (needs pyGIMLi or an ERT service)
24
26
  DEFAULT_METHODS = ("gravity", "magnetic")
25
27
 
26
28
  SHAPES: dict[str, str] = {
@@ -54,6 +56,8 @@ class Target:
54
56
  conductivity: float | None = None # mS/m, of the target (GPR)
55
57
  host_permittivity: float | None = None # relative, of the host (GPR)
56
58
  host_conductivity: float | None = None # mS/m, of the host (GPR)
59
+ resistivity: float | None = None # ohm m, of the target (ERT); overrides the material table
60
+ host_resistivity: float | None = None # ohm m, of the host (ERT)
57
61
 
58
62
  def validate(self) -> None:
59
63
  if self.shape not in SHAPES:
@@ -88,6 +92,11 @@ class Survey:
88
92
  date: str | None = None # ISO date of the survey; default: today
89
93
  gpr_frequency: float | None = None # MHz, antenna centre frequency; default: from the target depth
90
94
  gpr_system_performance: float = gpr.DEFAULT_SYSTEM_PERFORMANCE_DB # dB, largest two-way loss the system records
95
+ ert_array: str = ert.DEFAULT_ARRAY # dipole-dipole, wenner, schlumberger or gradient
96
+ ert_electrodes: int = ert.DEFAULT_ELECTRODES
97
+ ert_spacing: float | None = None # m between electrodes; default: spread about six times the target depth
98
+ ert_noise: float = ert.DEFAULT_NOISE_PERCENT # % of each apparent resistivity
99
+ ert_invert: bool = True # also invert noisy synthetic data to see whether the target is recovered
91
100
  field_total: float | None = None # nT; main field given directly instead of IGRF (all three together)
92
101
  field_inclination: float | None = None
93
102
  field_declination: float | None = None
@@ -157,10 +166,17 @@ class Report:
157
166
  if target.get("shape") != "dyke":
158
167
  target.pop("dip", None)
159
168
  survey = {k: v for k, v in self.survey.items() if v is not None}
169
+ for prefix, method in (("gpr_", "gpr"), ("ert_", "ert")): # settings of methods not asked for add nothing
170
+ if method not in self.methods:
171
+ survey = {k: v for k, v in survey.items() if not k.startswith(prefix)}
172
+
173
+ def literal(d): # JSON text, but Python booleans
174
+ return re.sub(r"(?<=: )(true|false)(?=,?\n)", lambda m: m.group(1).capitalize(),
175
+ json.dumps(d, indent=4, ensure_ascii=False))
160
176
  return SCRIPT.format(
161
177
  version=self.version,
162
- target=json.dumps(target, indent=4, ensure_ascii=False),
163
- survey=json.dumps(survey, indent=4, ensure_ascii=False),
178
+ target=literal(target),
179
+ survey=literal(survey),
164
180
  methods=json.dumps(self.methods),
165
181
  )
166
182
 
@@ -379,6 +395,28 @@ def _gpr(t: Target, s: Survey, x) -> MethodResult:
379
395
  assessment=a_, details=details)
380
396
 
381
397
 
398
+ def _ert(t: Target, s: Survey) -> MethodResult:
399
+ """Electrical resistivity tomography: locally with pyGIMLi, else through DETECTABILITY_ERT_URL, else not available."""
400
+ import os
401
+ try:
402
+ if ert.available():
403
+ d = ert.run(t, s)
404
+ elif os.environ.get("DETECTABILITY_ERT_URL"):
405
+ try:
406
+ d = ert.remote(asdict(t), asdict(s), os.environ["DETECTABILITY_ERT_URL"])
407
+ except Exception as e: # the service is down or too slow: say so instead of failing the whole report
408
+ return MethodResult("ert", False, f"the ERT service did not answer ({type(e).__name__}); try again later")
409
+ if not d.get("available", False):
410
+ return MethodResult("ert", False, d.get("reason", "the ERT service could not compute this target"))
411
+ else:
412
+ return MethodResult("ert", False, 'needs pyGIMLi: pip install "detectability[ert]"')
413
+ except KeyError as e:
414
+ return MethodResult("ert", False, str(e.args[0]) + " (give resistivity and host_resistivity directly)")
415
+ a = d.pop("assessment")
416
+ d["assessment"] = detect.Assessment(**a)
417
+ return MethodResult(**d)
418
+
419
+
382
420
  def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
383
421
  """Model ``target`` (a ``Target`` or dict) for each method and assess it for ``survey`` (a ``Survey`` or dict)."""
384
422
  t, s = _as(Target, target), _as(Survey, survey)
@@ -396,6 +434,9 @@ def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
396
434
  if method == "gpr":
397
435
  results.append(_gpr(t, s, x))
398
436
  continue
437
+ if method == "ert":
438
+ results.append(_ert(t, s))
439
+ continue
399
440
  contrast, units, problem = _contrast(t, method)
400
441
  if contrast is None:
401
442
  results.append(MethodResult(method, False, problem))
@@ -364,11 +364,42 @@ def cases() -> list[Case]:
364
364
  "Ten materials, from dry sand to fresh water", "m/ns", 6.0,
365
365
  float(np.max(np.abs(model_v - ref_v) / ref_v * 100.0))))
366
366
 
367
+ # --- ERT (finite elements, values stored by tools/ert_reference.py) ---------------------------------------------
368
+ out.extend(_ert_cases())
369
+
367
370
  # --- independent code ---------------------------------------------------------------------------------------
368
371
  out.extend(_harmonica_cases())
369
372
  return out
370
373
 
371
374
 
375
+ ERT_FILE = "ert_reference.json"
376
+
377
+
378
+ def ert_reference() -> dict | None:
379
+ """pyGIMLi forward-model values against analytic solutions, stored so the website needs no pyGIMLi."""
380
+ try:
381
+ return json.loads(resources.files("detectability").joinpath("data", ERT_FILE).read_text())
382
+ except FileNotFoundError:
383
+ return None
384
+
385
+
386
+ def _ert_cases() -> list[Case]:
387
+ ref = ert_reference()
388
+ if not ref:
389
+ return []
390
+ w = ref["wenner_two_layer"]
391
+ v = ref["pygimli"]
392
+ return [
393
+ Case("ert-halfspace", "Analytical limit", "ERT, homogeneous ground", f"pyGIMLi {v} finite elements",
394
+ "Apparent resistivity equals the true resistivity", "100 Ωm, 48 electrodes, dipole-dipole, 1,035 data", "",
395
+ 1.0, ref["halfspace_error_percent"]),
396
+ _profile_case("ert-wenner-two-layer", "Analytical limit", "ERT, two layers, Wenner sounding",
397
+ f"pyGIMLi {v} finite elements", "Image-series solution for two layers",
398
+ f"{w['rho1']:g} Ωm over {w['rho2']:g} Ωm, interface at {w['h']:g} m, a = 1–20 m", "Ωm", 2.0,
399
+ np.array(w["spacing"]), np.array(w["model"]), np.array(w["analytic"])),
400
+ ]
401
+
402
+
372
403
  def harmonica_reference() -> dict | None:
373
404
  """The stored Harmonica values and the versions they were computed with (None if the file is missing)."""
374
405
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectability
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets.
5
5
  Author: Mükremin Nergiz
6
6
  License-Expression: BSD-3-Clause
@@ -30,6 +30,8 @@ Provides-Extra: test
30
30
  Requires-Dist: pytest>=7; extra == "test"
31
31
  Provides-Extra: validation
32
32
  Requires-Dist: harmonica==0.7.0; extra == "validation"
33
+ Provides-Extra: ert
34
+ Requires-Dist: pygimli>=1.5; extra == "ert"
33
35
  Dynamic: license-file
34
36
 
35
37
  # Detectability
@@ -69,7 +71,7 @@ print(report.summary())
69
71
  ```
70
72
 
71
73
  ```text
72
- Detectability 0.4.0: sphere in limestone
74
+ Detectability 0.6.0: sphere in limestone
73
75
  - gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
74
76
  stations every 5 m or less, profile at least 153 m long
75
77
  - magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
@@ -79,6 +81,29 @@ Recommended method: gravity
79
81
  `report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
80
82
  including the modelled profiles and the assumptions.
81
83
 
84
+ ### How deep before a method loses it?
85
+
86
+ `depth_limit()` asks the inverse question — the one a survey plan turns on. Leave `depth_top` out: it is what the
87
+ search solves for.
88
+
89
+ ```python
90
+ from detectability import depth_limit
91
+
92
+ for method in ("gravity", "magnetic", "gpr"):
93
+ print(depth_limit({"shape": "sphere", "size": 20, "material": "air", "host": "limestone"},
94
+ {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}, method=method))
95
+ ```
96
+
97
+ ```text
98
+ gravity: detectable (S/N >= 5) down to 25.7 m
99
+ magnetic: never reaches S/N 5; S/N 2.88 even at 0.5 m
100
+ gpr: detectable (S/N >= 5) down to 25.2 m
101
+ ```
102
+
103
+ Pass `threshold=detect.MARGINAL` for the lower threshold, and `search=(shallow, deep)` to bound the sweep.
104
+ Sweep the target size and you have a detection-limit chart; the website draws one at
105
+ [/limits](https://detectability.mukreminnergiz.com/limits).
106
+
82
107
  Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
83
108
  geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
84
109
  in, and measured contrasts can be given instead.
@@ -87,10 +112,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
87
112
  (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
88
113
  FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
89
114
  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`:
115
+ recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
116
+ under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
117
+ apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
118
+ [pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
91
119
 
92
120
  ```python
93
- evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
121
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
94
122
  ```
95
123
 
96
124
  ## Modules
@@ -101,6 +129,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
101
129
  | `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) |
102
130
  | `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
131
  | `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) |
132
+ | `ert` | electrical resistivity tomography with pyGIMLi (optional): electrode layout, 2D finite-element forward model, inversion and recovery check; typical resistivities (Loke 1999, after Keller & Frischknecht 1966) |
104
133
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
105
134
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
106
135
  | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
@@ -145,6 +174,8 @@ python tools/harmonica_reference.py --check
145
174
  | Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
146
175
  | Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
147
176
  | Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
177
+ | Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
178
+ | Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
148
179
  | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
149
180
  | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
150
181
  | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
@@ -172,7 +203,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
172
203
 
173
204
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
174
205
 
175
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
206
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
176
207
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
177
208
 
178
209
  The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
@@ -4,9 +4,11 @@ pyproject.toml
4
4
  src/detectability/__init__.py
5
5
  src/detectability/constants.py
6
6
  src/detectability/detect.py
7
+ src/detectability/ert.py
7
8
  src/detectability/gpr.py
8
9
  src/detectability/gravity.py
9
10
  src/detectability/igrf.py
11
+ src/detectability/limits.py
10
12
  src/detectability/magnetics.py
11
13
  src/detectability/rocks.py
12
14
  src/detectability/survey.py
@@ -16,12 +18,16 @@ src/detectability.egg-info/SOURCES.txt
16
18
  src/detectability.egg-info/dependency_links.txt
17
19
  src/detectability.egg-info/requires.txt
18
20
  src/detectability.egg-info/top_level.txt
21
+ src/detectability/data/ert_reference.json
19
22
  src/detectability/data/harmonica_reference.json
20
23
  src/detectability/data/igrf14coeffs.txt
21
24
  tests/test_detect.py
25
+ tests/test_ert.py
26
+ tests/test_ert_pygimli.py
22
27
  tests/test_gpr.py
23
28
  tests/test_gravity.py
24
29
  tests/test_igrf.py
30
+ tests/test_limits.py
25
31
  tests/test_magnetics.py
26
32
  tests/test_survey.py
27
33
  tests/test_validation.py
@@ -1,5 +1,8 @@
1
1
  numpy>=1.24
2
2
 
3
+ [ert]
4
+ pygimli>=1.5
5
+
3
6
  [test]
4
7
  pytest>=7
5
8
 
@@ -0,0 +1,61 @@
1
+ import json
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from detectability import ert, evaluate, validation
7
+ from detectability.survey import Target
8
+
9
+ TUNNEL = {"shape": "horizontal_cylinder", "depth_top": 3, "size": 2, "material": "air", "host": "limestone"}
10
+
11
+
12
+ def test_typical_resistivity_is_inside_the_published_range():
13
+ for name, (typ, lo, hi) in ert.RESISTIVITY.items():
14
+ assert lo <= typ <= hi, name
15
+ assert ert.resistivity("limestone") == pytest.approx(np.sqrt(50 * 400), rel=0.01)
16
+
17
+
18
+ def test_geometry_outlines():
19
+ g = ert.geometry(Target(**TUNNEL))
20
+ assert g.depth_needed == 5 and g.area == pytest.approx(np.pi)
21
+ d = ert.geometry(Target(shape="dyke", depth_top=1, depth_bottom=11, size=2, dip=45))
22
+ assert d.polygon[2][0] == pytest.approx(1 + 10) # bottom shifted by the dip
23
+ assert ert.default_spacing(5, 48) == 0.5
24
+
25
+
26
+ def test_unavailable_without_pygimli_or_service(monkeypatch):
27
+ monkeypatch.setattr(ert, "available", lambda: False)
28
+ monkeypatch.delenv("DETECTABILITY_ERT_URL", raising=False)
29
+ r = evaluate(TUNNEL, {}, methods=["ert"]).result("ert")
30
+ assert not r.available and "pygimli" in r.reason.lower()
31
+
32
+
33
+ def test_remote_service_result_and_failure(monkeypatch):
34
+ monkeypatch.setattr(ert, "available", lambda: False)
35
+ monkeypatch.setenv("DETECTABILITY_ERT_URL", "http://127.0.0.1:9/evaluate")
36
+ fake = {"method": "ert", "available": True, "contrast": 7000.0, "contrast_units": "resistivity ratio",
37
+ "instrument": "48 electrodes, dipole-dipole", "instrument_noise": 3.0, "field_noise": None,
38
+ "x": [-1.0, 0.0, 1.0], "anomaly": [1.0, 10.0, 1.0], "details": {"recovered": True},
39
+ "assessment": {"method": "ert", "peak": 10.0, "peak_at": 0.0, "width": 1.0, "shape": "bell", "noise": 3.0,
40
+ "snr": 3.3, "verdict": "marginal", "station_spacing": 0.5, "profile_length": 23.5,
41
+ "assumptions": ["x"], "units": "%"}}
42
+ monkeypatch.setattr(ert, "remote", lambda t, s, url: json.loads(json.dumps(fake)))
43
+ rep = evaluate(TUNNEL, {}, methods=["ert"])
44
+ assert rep.result("ert").assessment.verdict == "marginal" and rep.recommended == "ert"
45
+
46
+ def down(t, s, url):
47
+ raise TimeoutError
48
+ monkeypatch.setattr(ert, "remote", down)
49
+ r = evaluate(TUNNEL, {}, methods=["ert"]).result("ert")
50
+ assert not r.available and "did not answer" in r.reason
51
+
52
+
53
+ def test_script_has_python_booleans_and_only_asked_settings():
54
+ rep = evaluate(TUNNEL, {"ert_invert": False}, methods=["gravity"])
55
+ s = rep.script()
56
+ assert "ert_" not in s and "gpr_" not in s and "true" not in s
57
+
58
+
59
+ def test_stored_validation_rows_pass():
60
+ rows = [c for c in validation.cases() if c.key.startswith("ert-")]
61
+ assert len(rows) == 2 and all(c.passed for c in rows)
@@ -0,0 +1,19 @@
1
+ import pytest
2
+
3
+ from detectability import evaluate
4
+
5
+ TUNNEL = {"shape": "horizontal_cylinder", "depth_top": 3, "size": 2, "material": "air", "host": "limestone"}
6
+
7
+ pg = pytest.importorskip("pygimli")
8
+
9
+
10
+ def test_ert_with_pygimli_tunnel_in_limestone():
11
+ r = evaluate(TUNNEL, {"ert_invert": False}, methods=["ert"]).result("ert")
12
+ assert r.available and 5 < r.details["max_anomaly_percent"] < 20
13
+ assert r.assessment.station_spacing == 0.5
14
+
15
+
16
+ def test_conductive_dyke_is_clear():
17
+ r = evaluate({"shape": "dyke", "depth_top": 1, "depth_bottom": 10, "size": 2, "material": "clay",
18
+ "host": "granite"}, {"ert_invert": False}, methods=["ert"]).result("ert")
19
+ assert r.assessment.verdict == "clearly detectable"
@@ -0,0 +1,93 @@
1
+ """The depth limit must agree with the assessment it inverts: detectable just above it, not just below."""
2
+
3
+ import pytest
4
+
5
+ from detectability import Target, depth_limit, detect, evaluate
6
+
7
+ SURVEY = {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}
8
+ CAVITY = {"shape": "sphere", "size": 20, "material": "air", "host": "limestone"}
9
+
10
+
11
+ def snr_at(target, depth, method):
12
+ return evaluate({**target, "depth_top": depth}, SURVEY, methods=[method]).results[0].assessment.snr
13
+
14
+
15
+ @pytest.mark.parametrize("method", ["gravity", "gpr"])
16
+ def test_limit_brackets_the_threshold(method):
17
+ limit = depth_limit(CAVITY, SURVEY, method=method)
18
+ assert limit.available and limit.depth is not None and not limit.beyond_search
19
+ assert snr_at(CAVITY, limit.depth * 0.99, method) >= detect.CLEAR
20
+ assert snr_at(CAVITY, limit.depth * 1.01, method) < detect.CLEAR
21
+
22
+
23
+ def test_marginal_reaches_deeper_than_clear():
24
+ clear = depth_limit(CAVITY, SURVEY, method="gravity")
25
+ marginal = depth_limit(CAVITY, SURVEY, method="gravity", threshold=detect.MARGINAL)
26
+ assert marginal.depth > clear.depth
27
+
28
+
29
+ def test_a_bigger_body_is_seen_deeper():
30
+ small = depth_limit({**CAVITY, "size": 10}, SURVEY, method="gravity")
31
+ large = depth_limit({**CAVITY, "size": 40}, SURVEY, method="gravity")
32
+ assert large.depth > small.depth
33
+
34
+
35
+ def test_depth_top_is_ignored_and_optional():
36
+ without = depth_limit(CAVITY, SURVEY, method="gravity")
37
+ with_depth = depth_limit({**CAVITY, "depth_top": 999}, SURVEY, method="gravity")
38
+ as_object = depth_limit(Target(shape="sphere", depth_top=1, size=20, material="air", host="limestone"),
39
+ SURVEY, method="gravity")
40
+ assert without.depth == pytest.approx(with_depth.depth, rel=1e-9)
41
+ assert without.depth == pytest.approx(as_object.depth, rel=1e-9)
42
+
43
+
44
+ def test_never_detected_reports_the_shallowest_signal():
45
+ limit = depth_limit(CAVITY, SURVEY, method="magnetic")
46
+ assert limit.depth is None and limit.available
47
+ assert 0 < limit.snr_shallowest < detect.CLEAR
48
+ assert "never reaches" in str(limit)
49
+
50
+
51
+ def test_still_detected_at_the_bottom_of_the_search():
52
+ limit = depth_limit(CAVITY, SURVEY, method="gravity", search=(0.5, 5.0))
53
+ assert limit.beyond_search and limit.depth == 5.0
54
+ assert "at least" in str(limit)
55
+
56
+
57
+ def test_unassessable_method_says_why():
58
+ limit = depth_limit({"shape": "sphere", "size": 20, "material": "magnetite", "host": "granite"},
59
+ SURVEY, method="gravity")
60
+ assert not limit.available and limit.depth is None
61
+ assert "no density for 'magnetite'" in limit.reason and "not assessed" in str(limit)
62
+
63
+
64
+ def test_body_keeps_its_thickness_while_it_moves_down():
65
+ dyke = {"shape": "dyke", "size": 10, "depth_top": 5, "depth_bottom": 45,
66
+ "material": "air", "host": "limestone"}
67
+ limit = depth_limit(dyke, SURVEY, method="gravity")
68
+ assert limit.depth is not None
69
+ # The 40 m thickness must be kept at the limit, or the answer would be about a different body.
70
+ assert snr_at({**dyke, "depth_bottom": limit.depth * 0.99 + 40}, limit.depth * 0.99, "gravity") >= detect.CLEAR
71
+
72
+
73
+ def test_a_narrow_search_still_lands_on_the_same_answer():
74
+ wide = depth_limit(CAVITY, SURVEY, method="gravity")
75
+ narrow = depth_limit(CAVITY, SURVEY, method="gravity", search=(20.0, 30.0), steps=6)
76
+ assert narrow.depth == pytest.approx(wide.depth, rel=1e-3)
77
+
78
+
79
+ @pytest.mark.parametrize("search", [(0, 100), (-1, 10), (100, 10), (10, 10)])
80
+ def test_a_bad_search_range_is_refused(search):
81
+ with pytest.raises(ValueError, match="search must be"):
82
+ depth_limit(CAVITY, SURVEY, method="gravity", search=search)
83
+
84
+
85
+ def test_too_few_steps_is_refused():
86
+ with pytest.raises(ValueError, match="steps must be"):
87
+ depth_limit(CAVITY, SURVEY, method="gravity", steps=1)
88
+
89
+
90
+ def test_an_invalid_target_is_refused():
91
+ with pytest.raises(ValueError):
92
+ depth_limit({"shape": "sphere", "size": -5, "material": "air", "host": "limestone"},
93
+ SURVEY, method="gravity")
@@ -1,7 +0,0 @@
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__"]
File without changes
File without changes