detectability 0.4.0__tar.gz → 0.5.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 (33) hide show
  1. {detectability-0.4.0/src/detectability.egg-info → detectability-0.5.0}/PKG-INFO +13 -5
  2. {detectability-0.4.0 → detectability-0.5.0}/README.md +10 -4
  3. {detectability-0.4.0 → detectability-0.5.0}/pyproject.toml +3 -1
  4. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/__init__.py +1 -1
  5. detectability-0.5.0/src/detectability/data/ert_reference.json +40 -0
  6. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/detect.py +1 -1
  7. detectability-0.5.0/src/detectability/ert.py +272 -0
  8. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/survey.py +45 -4
  9. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/validation.py +31 -0
  10. {detectability-0.4.0 → detectability-0.5.0/src/detectability.egg-info}/PKG-INFO +13 -5
  11. {detectability-0.4.0 → detectability-0.5.0}/src/detectability.egg-info/SOURCES.txt +4 -0
  12. {detectability-0.4.0 → detectability-0.5.0}/src/detectability.egg-info/requires.txt +3 -0
  13. detectability-0.5.0/tests/test_ert.py +61 -0
  14. detectability-0.5.0/tests/test_ert_pygimli.py +19 -0
  15. {detectability-0.4.0 → detectability-0.5.0}/LICENSE +0 -0
  16. {detectability-0.4.0 → detectability-0.5.0}/setup.cfg +0 -0
  17. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/constants.py +0 -0
  18. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/data/harmonica_reference.json +0 -0
  19. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/data/igrf14coeffs.txt +0 -0
  20. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/gpr.py +0 -0
  21. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/gravity.py +0 -0
  22. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/igrf.py +0 -0
  23. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/magnetics.py +0 -0
  24. {detectability-0.4.0 → detectability-0.5.0}/src/detectability/rocks.py +0 -0
  25. {detectability-0.4.0 → detectability-0.5.0}/src/detectability.egg-info/dependency_links.txt +0 -0
  26. {detectability-0.4.0 → detectability-0.5.0}/src/detectability.egg-info/top_level.txt +0 -0
  27. {detectability-0.4.0 → detectability-0.5.0}/tests/test_detect.py +0 -0
  28. {detectability-0.4.0 → detectability-0.5.0}/tests/test_gpr.py +0 -0
  29. {detectability-0.4.0 → detectability-0.5.0}/tests/test_gravity.py +0 -0
  30. {detectability-0.4.0 → detectability-0.5.0}/tests/test_igrf.py +0 -0
  31. {detectability-0.4.0 → detectability-0.5.0}/tests/test_magnetics.py +0 -0
  32. {detectability-0.4.0 → detectability-0.5.0}/tests/test_survey.py +0 -0
  33. {detectability-0.4.0 → detectability-0.5.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.5.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.5.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
@@ -87,10 +89,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
87
89
  (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
88
90
  FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
89
91
  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`:
92
+ recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
93
+ under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
94
+ apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
95
+ [pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
91
96
 
92
97
  ```python
93
- evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
98
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
94
99
  ```
95
100
 
96
101
  ## Modules
@@ -101,6 +106,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
101
106
  | `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
107
  | `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
108
  | `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) |
109
+ | `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
110
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
105
111
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
106
112
  | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
@@ -145,6 +151,8 @@ python tools/harmonica_reference.py --check
145
151
  | Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
146
152
  | Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
147
153
  | Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
154
+ | Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
155
+ | Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
148
156
  | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
149
157
  | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
150
158
  | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
@@ -172,7 +180,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
172
180
 
173
181
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
174
182
 
175
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
183
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
176
184
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
177
185
 
178
186
  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.5.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
@@ -53,10 +53,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
53
53
  (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
54
54
  FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
55
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`:
56
+ recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
57
+ under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
58
+ apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
59
+ [pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
57
60
 
58
61
  ```python
59
- evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
62
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
60
63
  ```
61
64
 
62
65
  ## Modules
@@ -67,6 +70,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
67
70
  | `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
71
  | `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
72
  | `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) |
73
+ | `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
74
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
71
75
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
72
76
  | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
@@ -111,6 +115,8 @@ python tools/harmonica_reference.py --check
111
115
  | Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
112
116
  | Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
113
117
  | Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
118
+ | Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
119
+ | Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
114
120
  | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
115
121
  | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
116
122
  | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
@@ -138,7 +144,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
138
144
 
139
145
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
140
146
 
141
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
147
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
142
148
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
143
149
 
144
150
  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.5.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"]
@@ -1,6 +1,6 @@
1
1
  """Detectability: pre-survey detectability, modelled and reproducible."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
4
4
 
5
5
  from .survey import DEFAULT_METHODS, METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
6
6
 
@@ -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())
@@ -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.5.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.5.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
@@ -87,10 +89,13 @@ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`
87
89
  (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
88
90
  FEREX 4.034 and GEM GSM-19G; and, on request, `gpr`: ground-penetrating radar, the echo of the target's top against
89
91
  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`:
92
+ recording window; and, on request, `ert`: electrical resistivity tomography, a 2D finite-element model of the target
93
+ under a line of electrodes (dipole-dipole, Wenner, Schlumberger or gradient array), the change it makes to the
94
+ apparent resistivities, and whether an inversion of noisy synthetic data recovers it. ERT needs
95
+ [pyGIMLi](https://www.pygimli.org): `pip install "detectability[ert]"`. The defaults are `gravity` and `magnetic`:
91
96
 
92
97
  ```python
93
- evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
98
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr", "ert"])
94
99
  ```
95
100
 
96
101
  ## Modules
@@ -101,6 +106,7 @@ evaluate(target, survey, methods=["gravity", "magnetic", "gradient", "gpr"])
101
106
  | `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
107
  | `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
108
  | `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) |
109
+ | `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
110
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
105
111
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
106
112
  | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
@@ -145,6 +151,8 @@ python tools/harmonica_reference.py --check
145
151
  | Analytical limit | GPR, reflection coefficient | (√ε1 − √ε2)/(√ε1 + √ε2) for lossless media | 0.0e+00 % | 1.0e-10 % |
146
152
  | Analytical limit | GPR, very large sphere | Flat reflector at the same range | 5.0e-06 % | 1.0e-04 % |
147
153
  | Published values | GPR, velocity of common materials | Davis & Annan (1989) table velocities (rounded to 0.01 m/ns) | 4.92 % | 6 % |
154
+ | Analytical limit | ERT, homogeneous ground | Apparent resistivity equals the true resistivity | 0.297 % | 1 % |
155
+ | Analytical limit | ERT, two layers, Wenner sounding | Image-series solution for two layers | 0.144 % | 2 % |
148
156
  | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
149
157
  | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
150
158
  | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
@@ -172,7 +180,7 @@ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and
172
180
 
173
181
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
174
182
 
175
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.4.0) [Computer software].
183
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.5.0) [Computer software].
176
184
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
177
185
 
178
186
  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/ert.py
7
8
  src/detectability/gpr.py
8
9
  src/detectability/gravity.py
9
10
  src/detectability/igrf.py
@@ -16,9 +17,12 @@ src/detectability.egg-info/SOURCES.txt
16
17
  src/detectability.egg-info/dependency_links.txt
17
18
  src/detectability.egg-info/requires.txt
18
19
  src/detectability.egg-info/top_level.txt
20
+ src/detectability/data/ert_reference.json
19
21
  src/detectability/data/harmonica_reference.json
20
22
  src/detectability/data/igrf14coeffs.txt
21
23
  tests/test_detect.py
24
+ tests/test_ert.py
25
+ tests/test_ert_pygimli.py
22
26
  tests/test_gpr.py
23
27
  tests/test_gravity.py
24
28
  tests/test_igrf.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"
File without changes
File without changes