detectability 0.2.1__tar.gz → 0.3.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 (28) hide show
  1. {detectability-0.2.1/src/detectability.egg-info → detectability-0.3.0}/PKG-INFO +19 -7
  2. {detectability-0.2.1 → detectability-0.3.0}/README.md +17 -6
  3. {detectability-0.2.1 → detectability-0.3.0}/pyproject.toml +2 -1
  4. detectability-0.3.0/src/detectability/__init__.py +7 -0
  5. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/detect.py +26 -6
  6. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/survey.py +47 -12
  7. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/validation.py +18 -0
  8. {detectability-0.2.1 → detectability-0.3.0/src/detectability.egg-info}/PKG-INFO +19 -7
  9. {detectability-0.2.1 → detectability-0.3.0}/tests/test_detect.py +11 -2
  10. {detectability-0.2.1 → detectability-0.3.0}/tests/test_survey.py +40 -0
  11. detectability-0.2.1/src/detectability/__init__.py +0 -7
  12. {detectability-0.2.1 → detectability-0.3.0}/LICENSE +0 -0
  13. {detectability-0.2.1 → detectability-0.3.0}/setup.cfg +0 -0
  14. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/constants.py +0 -0
  15. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/data/harmonica_reference.json +0 -0
  16. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/data/igrf14coeffs.txt +0 -0
  17. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/gravity.py +0 -0
  18. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/igrf.py +0 -0
  19. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/magnetics.py +0 -0
  20. {detectability-0.2.1 → detectability-0.3.0}/src/detectability/rocks.py +0 -0
  21. {detectability-0.2.1 → detectability-0.3.0}/src/detectability.egg-info/SOURCES.txt +0 -0
  22. {detectability-0.2.1 → detectability-0.3.0}/src/detectability.egg-info/dependency_links.txt +0 -0
  23. {detectability-0.2.1 → detectability-0.3.0}/src/detectability.egg-info/requires.txt +0 -0
  24. {detectability-0.2.1 → detectability-0.3.0}/src/detectability.egg-info/top_level.txt +0 -0
  25. {detectability-0.2.1 → detectability-0.3.0}/tests/test_gravity.py +0 -0
  26. {detectability-0.2.1 → detectability-0.3.0}/tests/test_igrf.py +0 -0
  27. {detectability-0.2.1 → detectability-0.3.0}/tests/test_magnetics.py +0 -0
  28. {detectability-0.2.1 → detectability-0.3.0}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectability
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets.
5
5
  Author: Mükremin Nergiz
6
6
  License-Expression: BSD-3-Clause
@@ -9,6 +9,7 @@ Project-URL: Documentation, https://detectability.mukreminnergiz.com/method
9
9
  Project-URL: Validation, https://detectability.mukreminnergiz.com/validation
10
10
  Project-URL: Repository, https://github.com/mukremin-nergiz/detectability
11
11
  Project-URL: Issues, https://github.com/mukremin-nergiz/detectability/issues
12
+ Project-URL: Changelog, https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md
12
13
  Project-URL: Citation, https://doi.org/10.5281/zenodo.23931815
13
14
  Keywords: geophysics,gravity,magnetics,survey design,near-surface,forward modelling,IGRF
14
15
  Classifier: Development Status :: 3 - Alpha
@@ -68,7 +69,7 @@ print(report.summary())
68
69
  ```
69
70
 
70
71
  ```text
71
- Detectability 0.2.1: sphere in limestone
72
+ Detectability 0.3.0: sphere in limestone
72
73
  - gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
73
74
  stations every 5 m or less, profile at least 153 m long
74
75
  - magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
@@ -82,16 +83,24 @@ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `d
82
83
  geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
83
84
  in, and measured contrasts can be given instead.
84
85
 
86
+ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
87
+ (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
88
+ FEREX 4.034 and GEM GSM-19G. The defaults are `gravity` and `magnetic`:
89
+
90
+ ```python
91
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
92
+ ```
93
+
85
94
  ## Modules
86
95
 
87
96
  | Module | Contents |
88
97
  |---|---|
89
98
  | `survey` | `evaluate()`: target and survey in, report and reproduction script out |
90
99
  | `gravity` | sphere, horizontal cylinder, right rectangular prism (Nagy; Plouff) and its gradient tensor, vertical cylinder (exact, by a rim integral) and its gradient tensor, vertical step, dyke, 2D polygon (Talwani; Won & Bevis) |
91
- | `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation |
100
+ | `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) |
92
101
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
93
102
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
94
- | `detect` | instrument and field noise, signal-to-noise ratio, verdict, station spacing, profile length |
103
+ | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
95
104
 
96
105
  ## Validation
97
106
 
@@ -117,6 +126,7 @@ python tools/harmonica_reference.py --check
117
126
  | Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
118
127
  | Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
119
128
  | Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
129
+ | Independent formula | Sphere, magnetic gradient (gradiometer reading) | Closed-form vertical-dipole field at both sensors | 7.8e-14 % | 1.0e-10 % |
120
130
  | Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
121
131
  | Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
122
132
  | Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
@@ -147,16 +157,18 @@ pytest
147
157
  Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
148
158
  crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
149
159
  not guarantees. Instrument presets come from manufacturers' data sheets (`detect.INSTRUMENTS` lists each figure and its
150
- source); field noise, which usually dominates, is a stated assumption that you can change.
160
+ source); field noise, which usually dominates, is a stated assumption that you can change. For gradiometers the default
161
+ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and walking noise depend on the site.
151
162
 
152
163
  ## Citation
153
164
 
154
165
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
155
166
 
156
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.2.1) [Computer software].
167
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.3.0) [Computer software].
157
168
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
158
169
 
159
- The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
170
+ The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
171
+ releases: [CHANGELOG.md](https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md).
160
172
 
161
173
  ## Author
162
174
 
@@ -35,7 +35,7 @@ print(report.summary())
35
35
  ```
36
36
 
37
37
  ```text
38
- Detectability 0.2.1: sphere in limestone
38
+ Detectability 0.3.0: sphere in limestone
39
39
  - gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
40
40
  stations every 5 m or less, profile at least 153 m long
41
41
  - magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
@@ -49,16 +49,24 @@ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `d
49
49
  geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
50
50
  in, and measured contrasts can be given instead.
51
51
 
52
+ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
53
+ (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
54
+ FEREX 4.034 and GEM GSM-19G. The defaults are `gravity` and `magnetic`:
55
+
56
+ ```python
57
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
58
+ ```
59
+
52
60
  ## Modules
53
61
 
54
62
  | Module | Contents |
55
63
  |---|---|
56
64
  | `survey` | `evaluate()`: target and survey in, report and reproduction script out |
57
65
  | `gravity` | sphere, horizontal cylinder, right rectangular prism (Nagy; Plouff) and its gradient tensor, vertical cylinder (exact, by a rim integral) and its gradient tensor, vertical step, dyke, 2D polygon (Talwani; Won & Bevis) |
58
- | `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation |
66
+ | `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) |
59
67
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
60
68
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
61
- | `detect` | instrument and field noise, signal-to-noise ratio, verdict, station spacing, profile length |
69
+ | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
62
70
 
63
71
  ## Validation
64
72
 
@@ -84,6 +92,7 @@ python tools/harmonica_reference.py --check
84
92
  | Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
85
93
  | Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
86
94
  | Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
95
+ | Independent formula | Sphere, magnetic gradient (gradiometer reading) | Closed-form vertical-dipole field at both sensors | 7.8e-14 % | 1.0e-10 % |
87
96
  | Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
88
97
  | Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
89
98
  | Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
@@ -114,16 +123,18 @@ pytest
114
123
  Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
115
124
  crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
116
125
  not guarantees. Instrument presets come from manufacturers' data sheets (`detect.INSTRUMENTS` lists each figure and its
117
- source); field noise, which usually dominates, is a stated assumption that you can change.
126
+ source); field noise, which usually dominates, is a stated assumption that you can change. For gradiometers the default
127
+ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and walking noise depend on the site.
118
128
 
119
129
  ## Citation
120
130
 
121
131
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
122
132
 
123
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.2.1) [Computer software].
133
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.3.0) [Computer software].
124
134
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
125
135
 
126
- The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
136
+ The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
137
+ releases: [CHANGELOG.md](https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md).
127
138
 
128
139
  ## Author
129
140
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "detectability"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets."
9
9
  readme = "README.md"
10
10
  keywords = ["geophysics", "gravity", "magnetics", "survey design", "near-surface", "forward modelling", "IGRF"]
@@ -31,6 +31,7 @@ Documentation = "https://detectability.mukreminnergiz.com/method"
31
31
  Validation = "https://detectability.mukreminnergiz.com/validation"
32
32
  Repository = "https://github.com/mukremin-nergiz/detectability"
33
33
  Issues = "https://github.com/mukremin-nergiz/detectability/issues"
34
+ Changelog = "https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md"
34
35
  Citation = "https://doi.org/10.5281/zenodo.23931815"
35
36
 
36
37
  [project.optional-dependencies]
@@ -0,0 +1,7 @@
1
+ """Detectability: pre-survey detectability, modelled and reproducible."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .survey import DEFAULT_METHODS, METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
6
+
7
+ __all__ = ["evaluate", "Target", "Survey", "Report", "SHAPES", "METHODS", "DEFAULT_METHODS", "__version__"]
@@ -19,11 +19,12 @@ STATIONS_ACROSS = 5
19
19
  class Instrument:
20
20
  """An instrument preset: its noise, what the figure means and where it comes from (checked 4 Nov 2026)."""
21
21
 
22
- method: str # "gravity" or "magnetic"
23
- noise: float # one standard deviation of a reading, in µGal (gravity) or nT (magnetic)
22
+ method: str # "gravity", "magnetic" or "gradient" (magnetic vertical gradient)
23
+ noise: float # one standard deviation of a reading, in µGal (gravity) or nT (magnetic and gradient)
24
24
  basis: str # what the manufacturer's figure is
25
25
  reference: str # instrument the figure is taken from
26
26
  source: str # data sheet or publication
27
+ baseline: float | None = None # m, vertical distance between the two sensors (gradiometers)
27
28
 
28
29
 
29
30
  INSTRUMENTS: dict[str, Instrument] = {
@@ -45,18 +46,37 @@ INSTRUMENTS: dict[str, Instrument] = {
45
46
  "caesium vapour": Instrument(
46
47
  "magnetic", 0.008, "noise < 0.008 nT/√Hz RMS, i.e. at 1 Hz", "Geometrics G-858",
47
48
  "https://www.geomatrix.co.uk/land-products/magnetic/g-858/"),
49
+ # Vertical gradiometers: two sensors one above the other; the reading is the lower minus the upper sensor, in nT
50
+ # over the baseline (as Bartington and Foerster report it; nT/m only for a 1 m baseline).
51
+ "Bartington Grad601": Instrument(
52
+ "gradient", 0.1, "noise 100 pT peak-to-peak maximum, taken as one standard deviation to be conservative",
53
+ "Bartington Grad-01-1000L sensor (Grad601)", "https://www.bartingtondownloads.com/wp-content/uploads/DS0060.pdf",
54
+ baseline=1.0),
55
+ "Sensys FGM650": Instrument(
56
+ "gradient", 0.04, "noise < 40 pT rms/√Hz at 1 Hz", "Sensys FGM650 fluxgate gradiometer",
57
+ "https://sensysmagnetometer.com/de/produkte/fgm650-gradiometer/", baseline=0.65),
58
+ "Foerster FEREX 4.034": Instrument(
59
+ "gradient", 0.04, "noise < 40 pT/√Hz at 1 Hz (MG-10 probe)", "Foerster FEREX 4.034 with MG-10-550 probe",
60
+ "https://www.foerstergroup.com/FOERSTER/downloads/product/ferex/FOERSTER_FLY_FEREX4.034_DE.pdf", baseline=0.55),
61
+ "GEM GSM-19G": Instrument(
62
+ "gradient", 0.031, "0.022 nT at 1 Hz per sensor; two sensors in difference give 0.022 × √2",
63
+ "GEM Systems GSM-19G Overhauser gradiometer (one staff section, 0.56 m, per the manual)",
64
+ "https://www.gemsys.ca/wp-content/themes/gemsystems/pdf/GEM_Overhauser_GSM_19_fin.pdf", baseline=0.56),
48
65
  }
49
- # Fluxgate gradiometers (e.g. Bartington Grad601) measure a vertical gradient, not the total-field anomaly modelled
50
- # here, so they are not offered until a gradiometer response is implemented.
51
66
 
52
67
  GRAVIMETERS_UGAL: dict[str, float] = {k: v.noise for k, v in INSTRUMENTS.items() if v.method == "gravity"}
53
68
  MAGNETOMETERS_NT: dict[str, float] = {k: v.noise for k, v in INSTRUMENTS.items() if v.method == "magnetic"}
69
+ GRADIOMETERS_NT: dict[str, float] = {k: v.noise for k, v in INSTRUMENTS.items() if v.method == "gradient"}
54
70
 
55
71
  # Field noise is what remains after corrections and usually dominates the instrument noise.
56
72
  DEFAULT_FIELD_NOISE_UGAL = 10.0 # microgravity: tares, drift residuals, terrain, cultural noise
57
73
  DEFAULT_FIELD_NOISE_NT = 2.0 # quiet rural site after diurnal correction; urban and industrial sites are far noisier
74
+ # Gradient surveys cancel diurnal variation and the regional field, so their field noise is mainly soil, heading and
75
+ # walking noise. 0.5 nT is an assumption, not a published figure (the Grad601 manual asks for a set-up spot that varies
76
+ # by less than about 0.5 nT over 1 m); change it for the site.
77
+ DEFAULT_FIELD_NOISE_GRADIENT_NT = 0.5
58
78
 
59
- UNITS = {"gravity": "µGal", "magnetic": "nT"}
79
+ UNITS = {"gravity": "µGal", "magnetic": "nT", "gradient": "nT"}
60
80
 
61
81
 
62
82
  def effective_noise(instrument: float, field_noise: float) -> float:
@@ -143,7 +163,7 @@ def assess(x, anomaly, noise: float, depth: float, method: str = "gravity") -> A
143
163
  f"Station spacing allows at least {STATIONS_ACROSS} stations across the anomaly width ({width:.3g} m).",
144
164
  "Flat topography, homogeneous host and no regional trend; terrain and regional effects reduce the margin.",
145
165
  ]
146
- if method == "magnetic":
166
+ if method in ("magnetic", "gradient"):
147
167
  assumptions.append("Peak is the largest absolute value of the (often two-lobed) anomaly; no remanence unless given.")
148
168
  return Assessment(method, peak, float(x[peak_i]), width, shape, noise, snr, verdict, spacing, length, assumptions,
149
169
  unit)
@@ -20,7 +20,8 @@ import numpy as np
20
20
  from . import __version__, detect, gravity, igrf, magnetics, rocks
21
21
  from .constants import SI_TO_UGAL
22
22
 
23
- METHODS = ("gravity", "magnetic")
23
+ METHODS = ("gravity", "magnetic", "gradient") # gradient: magnetic vertical gradient (two-sensor gradiometer)
24
+ DEFAULT_METHODS = ("gravity", "magnetic")
24
25
 
25
26
  SHAPES: dict[str, str] = {
26
27
  "sphere": "Sphere (cavity, boulder, buried object). size = diameter.",
@@ -72,6 +73,11 @@ class Survey:
72
73
  magnetic_field_noise: float = detect.DEFAULT_FIELD_NOISE_NT # nT
73
74
  magnetic_instrument_noise: float | None = None # nT, overrides the magnetometer preset
74
75
  sensor_height: float = 0.5 # m, magnetometer sensor above ground
76
+ gradiometer: str = "Bartington Grad601"
77
+ gradient_field_noise: float = detect.DEFAULT_FIELD_NOISE_GRADIENT_NT # nT, of the gradiometer reading
78
+ gradient_instrument_noise: float | None = None # nT, overrides the gradiometer preset
79
+ gradiometer_height: float = 0.2 # m, lower sensor above ground (Bartington: about 20 cm in normal use)
80
+ gradiometer_baseline: float | None = None # m between the sensors; default: the gradiometer preset's
75
81
  profile_azimuth: float = 0.0 # degrees east of north; 2D targets strike at right angles to it
76
82
  latitude: float | None = None # for the main field (IGRF-14); needed for magnetics
77
83
  longitude: float | None = None
@@ -261,7 +267,13 @@ def _main_field(s: Survey):
261
267
  "inclination": round(f.inclination, 3), "declination": round(f.declination, 3)}
262
268
 
263
269
 
264
- def evaluate(target, survey=None, methods=METHODS) -> Report:
270
+ def _gradient(t: Target, x, chi, amb, s: Survey, baseline: float):
271
+ """Gradiometer reading: total-field anomaly at the lower sensor minus that at the upper sensor, in nT."""
272
+ low, high = replace(s, sensor_height=s.gradiometer_height), replace(s, sensor_height=s.gradiometer_height + baseline)
273
+ return _magnetic(t, x, chi, amb, low) - _magnetic(t, x, chi, amb, high)
274
+
275
+
276
+ def evaluate(target, survey=None, methods=DEFAULT_METHODS) -> Report:
265
277
  """Model ``target`` (a ``Target`` or dict) for each method and assess it for ``survey`` (a ``Survey`` or dict)."""
266
278
  t, s = _as(Target, target), _as(Survey, survey)
267
279
  t.validate()
@@ -295,18 +307,36 @@ def evaluate(target, survey=None, methods=METHODS) -> Report:
295
307
  results.append(MethodResult(method, False, "needs the survey location (latitude, longitude) for the "
296
308
  "main field, or field_total/inclination/declination"))
297
309
  continue
298
- inst = s.magnetic_instrument_noise if s.magnetic_instrument_noise is not None else \
299
- detect.MAGNETOMETERS_NT.get(s.magnetometer)
300
- if inst is None:
301
- results.append(MethodResult(method, False, f"unknown magnetometer {s.magnetometer!r}"))
302
- continue
303
- noise = detect.effective_noise(inst, s.magnetic_field_noise)
304
- anomaly = _magnetic(t, x, contrast, amb, s)
305
- res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.magnetometer,
306
- instrument_noise=inst, field_noise=s.magnetic_field_noise, main_field=info)
310
+ if method == "magnetic":
311
+ inst = s.magnetic_instrument_noise if s.magnetic_instrument_noise is not None else \
312
+ detect.MAGNETOMETERS_NT.get(s.magnetometer)
313
+ if inst is None:
314
+ results.append(MethodResult(method, False, f"unknown magnetometer {s.magnetometer!r}"))
315
+ continue
316
+ noise = detect.effective_noise(inst, s.magnetic_field_noise)
317
+ anomaly = _magnetic(t, x, contrast, amb, s)
318
+ res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.magnetometer,
319
+ instrument_noise=inst, field_noise=s.magnetic_field_noise, main_field=info)
320
+ else:
321
+ preset = detect.INSTRUMENTS.get(s.gradiometer)
322
+ inst = s.gradient_instrument_noise if s.gradient_instrument_noise is not None else \
323
+ (preset.noise if preset and preset.method == "gradient" else None)
324
+ baseline = s.gradiometer_baseline if s.gradiometer_baseline is not None else \
325
+ (preset.baseline if preset and preset.method == "gradient" else None)
326
+ if inst is None or baseline is None:
327
+ results.append(MethodResult(method, False, f"unknown gradiometer {s.gradiometer!r} (or give "
328
+ "gradient_instrument_noise and gradiometer_baseline)"))
329
+ continue
330
+ if baseline <= 0 or s.gradiometer_height < 0:
331
+ raise ValueError("gradiometer_baseline must be positive and gradiometer_height zero or positive")
332
+ noise = detect.effective_noise(inst, s.gradient_field_noise)
333
+ anomaly = _gradient(t, x, contrast, amb, s, baseline)
334
+ res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.gradiometer,
335
+ instrument_noise=inst, field_noise=s.gradient_field_noise, main_field=info)
307
336
  a = detect.assess(x, anomaly, noise, depth=t.depth_top, method=method)
308
337
  preset = detect.INSTRUMENTS.get(res.instrument)
309
- overridden = (s.gravity_instrument_noise if method == "gravity" else s.magnetic_instrument_noise) is not None
338
+ overridden = {"gravity": s.gravity_instrument_noise, "magnetic": s.magnetic_instrument_noise,
339
+ "gradient": s.gradient_instrument_noise}[method] is not None
310
340
  if preset and not overridden:
311
341
  a.assumptions.append(f"{res.instrument}: {preset.noise:g} {a.units} per reading ({preset.basis}; "
312
342
  f"{preset.reference} data sheet).")
@@ -315,6 +345,11 @@ def evaluate(target, survey=None, methods=METHODS) -> Report:
315
345
  if method == "magnetic":
316
346
  a.assumptions.append(f"Sensor {s.sensor_height:g} m above ground; profile azimuth {s.profile_azimuth:g}°; "
317
347
  "induced magnetisation only, no demagnetisation.")
348
+ if method == "gradient":
349
+ top = s.gradiometer_height + baseline
350
+ a.assumptions.append(f"Reading: lower sensor ({s.gradiometer_height:g} m above ground) minus upper sensor "
351
+ f"({top:g} m), in nT over the {baseline:g} m baseline; diurnal variation and the regional "
352
+ f"field cancel. Profile azimuth {s.profile_azimuth:g}°; induced magnetisation only.")
318
353
  res.x, res.anomaly, res.assessment = x.round(4).tolist(), anomaly.tolist(), a
319
354
  results.append(res)
320
355
 
@@ -240,6 +240,24 @@ def cases() -> list[Case]:
240
240
  gravity.dyke(x, 3, 15, 4, 500.0, centre=7) * SI_TO_UGAL,
241
241
  gravity.polygon(x, [(5, 3), (9, 3), (9, 15), (5, 15)], 500.0) * SI_TO_UGAL))
242
242
 
243
+ # gradiometer reading of a sphere at the pole: closed-form dipole field at the two sensor heights
244
+ x = np.linspace(-20, 20, 81)
245
+ depth, radius, low, base = 3.0, 0.5, 0.2, 1.0
246
+ m = CHI * POLE.total * 1e-9 / MU0 * 4 / 3 * np.pi * radius**3
247
+
248
+ def _pole_dipole(h):
249
+ z = depth + h # depth of the centre below the sensor
250
+ return MU0 / (4 * np.pi) * m * (2 * z**2 - x**2) / (x**2 + z**2) ** 2.5 * 1e9
251
+
252
+ from .survey import _gradient, Survey, Target
253
+ sphere_target = Target("sphere", depth - radius, 2 * radius)
254
+ reading = _gradient(sphere_target, x, CHI, POLE, Survey(gradiometer_height=low), base)
255
+ out.append(_profile_case(
256
+ "gradient-sphere-pole", "Independent formula", "Sphere, magnetic gradient (gradiometer reading)",
257
+ "Total-field model at the lower minus the upper sensor", "Closed-form vertical-dipole field at both sensors",
258
+ "Radius 0.5 m, centre at 3 m, sensors 0.2 and 1.2 m above ground, at the pole", "nT", 1e-10, x, reading,
259
+ _pole_dipole(low) - _pole_dipole(low + base)))
260
+
243
261
  # --- numerical integration -------------------------------------------------------------------------------
244
262
  x = np.linspace(-40, 40, 81)
245
263
  box = ((-3.0, 3.0), (-4.0, 4.0), (2.0, 6.0))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectability
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Pre-survey detectability, modelled and reproducible: forward-modelled gravity and magnetic responses, station spacing and a detectability verdict for near-surface targets.
5
5
  Author: Mükremin Nergiz
6
6
  License-Expression: BSD-3-Clause
@@ -9,6 +9,7 @@ Project-URL: Documentation, https://detectability.mukreminnergiz.com/method
9
9
  Project-URL: Validation, https://detectability.mukreminnergiz.com/validation
10
10
  Project-URL: Repository, https://github.com/mukremin-nergiz/detectability
11
11
  Project-URL: Issues, https://github.com/mukremin-nergiz/detectability/issues
12
+ Project-URL: Changelog, https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md
12
13
  Project-URL: Citation, https://doi.org/10.5281/zenodo.23931815
13
14
  Keywords: geophysics,gravity,magnetics,survey design,near-surface,forward modelling,IGRF
14
15
  Classifier: Development Status :: 3 - Alpha
@@ -68,7 +69,7 @@ print(report.summary())
68
69
  ```
69
70
 
70
71
  ```text
71
- Detectability 0.2.1: sphere in limestone
72
+ Detectability 0.3.0: sphere in limestone
72
73
  - gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
73
74
  stations every 5 m or less, profile at least 153 m long
74
75
  - magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
@@ -82,16 +83,24 @@ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `d
82
83
  geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
83
84
  in, and measured contrasts can be given instead.
84
85
 
86
+ Methods: `gravity`, `magnetic` (total-field anomaly) and, on request, `gradient`: the reading of a vertical gradiometer
87
+ (lower minus upper sensor, in nT over the baseline), with presets for the Bartington Grad601, Sensys FGM650, Foerster
88
+ FEREX 4.034 and GEM GSM-19G. The defaults are `gravity` and `magnetic`:
89
+
90
+ ```python
91
+ evaluate(target, survey, methods=["gravity", "magnetic", "gradient"])
92
+ ```
93
+
85
94
  ## Modules
86
95
 
87
96
  | Module | Contents |
88
97
  |---|---|
89
98
  | `survey` | `evaluate()`: target and survey in, report and reproduction script out |
90
99
  | `gravity` | sphere, horizontal cylinder, right rectangular prism (Nagy; Plouff) and its gradient tensor, vertical cylinder (exact, by a rim integral) and its gradient tensor, vertical step, dyke, 2D polygon (Talwani; Won & Bevis) |
91
- | `magnetics` | total-field anomaly: sphere, horizontal cylinder, prism, vertical cylinder, 2D polygon, dyke (any dip), vertical contact; induced and remanent magnetisation |
100
+ | `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) |
92
101
  | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
93
102
  | `rocks` | typical densities and magnetic susceptibilities with their ranges (Telford et al. 1990) |
94
- | `detect` | instrument and field noise, signal-to-noise ratio, verdict, station spacing, profile length |
103
+ | `detect` | instrument presets with their sources (gravimeters, magnetometers, gradiometers), field noise, signal-to-noise ratio, verdict, station spacing, profile length |
95
104
 
96
105
  ## Validation
97
106
 
@@ -117,6 +126,7 @@ python tools/harmonica_reference.py --check
117
126
  | Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
118
127
  | Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
119
128
  | Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
129
+ | Independent formula | Sphere, magnetic gradient (gradiometer reading) | Closed-form vertical-dipole field at both sensors | 7.8e-14 % | 1.0e-10 % |
120
130
  | Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
121
131
  | Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
122
132
  | Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
@@ -147,16 +157,18 @@ pytest
147
157
  Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
148
158
  crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
149
159
  not guarantees. Instrument presets come from manufacturers' data sheets (`detect.INSTRUMENTS` lists each figure and its
150
- source); field noise, which usually dominates, is a stated assumption that you can change.
160
+ source); field noise, which usually dominates, is a stated assumption that you can change. For gradiometers the default
161
+ field noise (0.5 nT) is an assumption, not a published figure: soil, heading and walking noise depend on the site.
151
162
 
152
163
  ## Citation
153
164
 
154
165
  See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
155
166
 
156
- > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.2.1) [Computer software].
167
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.3.0) [Computer software].
157
168
  > Zenodo. https://doi.org/10.5281/zenodo.23931815
158
169
 
159
- The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
170
+ The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo. Changes between
171
+ releases: [CHANGELOG.md](https://github.com/mukremin-nergiz/detectability/blob/main/CHANGELOG.md).
160
172
 
161
173
  ## Author
162
174
 
@@ -66,8 +66,17 @@ def test_void_in_limestone_magnetic_example():
66
66
  def test_instrument_presets_have_sources():
67
67
  assert detect.INSTRUMENTS
68
68
  for name, inst in detect.INSTRUMENTS.items():
69
- assert inst.method in ("gravity", "magnetic"), name
69
+ assert inst.method in ("gravity", "magnetic", "gradient"), name
70
70
  assert inst.noise > 0 and inst.source.startswith("https://") and inst.reference, name
71
71
  assert detect.GRAVIMETERS_UGAL["Scintrex CG-6"] == 5.0
72
72
  assert detect.MAGNETOMETERS_NT["Overhauser"] == 0.022
73
- assert "fluxgate gradiometer" not in detect.MAGNETOMETERS_NT # measures a gradient, not the total field
73
+ assert "Bartington Grad601" not in detect.MAGNETOMETERS_NT # a gradiometer reading, not the total field
74
+
75
+
76
+ def test_gradiometer_presets_have_a_baseline_and_a_source():
77
+ grads = {k: v for k, v in detect.INSTRUMENTS.items() if v.method == "gradient"}
78
+ assert len(grads) >= 3 and set(grads) == set(detect.GRADIOMETERS_NT)
79
+ for name, inst in grads.items():
80
+ assert inst.baseline and 0.3 <= inst.baseline <= 1.5, name
81
+ assert inst.source.startswith("https://") and inst.noise > 0, name
82
+ assert all(v.baseline is None for v in detect.INSTRUMENTS.values() if v.method != "gradient")
@@ -75,3 +75,43 @@ def test_dataclass_inputs_are_not_modified():
75
75
  s = Survey(latitude=39.75, longitude=37.02)
76
76
  evaluate(Target(**VOID), s)
77
77
  assert s.date is None
78
+
79
+
80
+ SITE = {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"}
81
+ SHALLOW_DYKE = {"shape": "dyke", "depth_top": 0.4, "depth_bottom": 1.2, "size": 0.8, "material": "basalt",
82
+ "host": "sandstone"}
83
+
84
+
85
+ def test_default_methods_unchanged_and_gradient_on_request():
86
+ from detectability import DEFAULT_METHODS, METHODS
87
+ assert DEFAULT_METHODS == ("gravity", "magnetic") and "gradient" in METHODS
88
+ assert [r.method for r in evaluate(SHALLOW_DYKE, SITE).results] == ["gravity", "magnetic"]
89
+ r = evaluate(SHALLOW_DYKE, SITE, ["gradient"]).results[0]
90
+ assert r.available and r.instrument == "Bartington Grad601" and r.assessment.units == "nT"
91
+
92
+
93
+ def test_gradient_is_the_difference_of_two_sensor_heights():
94
+ import numpy as np
95
+ low = evaluate(SHALLOW_DYKE, {**SITE, "sensor_height": 0.3}, ["magnetic"]).results[0]
96
+ high = evaluate(SHALLOW_DYKE, {**SITE, "sensor_height": 0.95}, ["magnetic"]).results[0]
97
+ g = evaluate(SHALLOW_DYKE, {**SITE, "gradiometer": "Sensys FGM650", "gradiometer_height": 0.3}, ["gradient"])
98
+ assert np.allclose(g.results[0].anomaly, np.subtract(low.anomaly, high.anomaly), rtol=0, atol=1e-12)
99
+
100
+
101
+ def test_gradient_favours_shallow_targets():
102
+ deep = {"shape": "sphere", "depth_top": 15, "size": 20, "material": "air", "host": "limestone"}
103
+ shallow = evaluate(SHALLOW_DYKE, SITE, ["magnetic", "gradient"])
104
+ assert shallow.recommended == "gradient"
105
+ far = evaluate(deep, SITE, ["magnetic", "gradient"]).results
106
+ assert far[1].assessment.snr < far[0].assessment.snr
107
+
108
+
109
+ def test_custom_gradiometer_and_errors():
110
+ import pytest
111
+ r = evaluate(SHALLOW_DYKE, {**SITE, "gradiometer": "home-made", "gradient_instrument_noise": 0.05,
112
+ "gradiometer_baseline": 0.5}, ["gradient"]).results[0]
113
+ assert r.available and r.instrument_noise == 0.05
114
+ assert not evaluate(SHALLOW_DYKE, {**SITE, "gradiometer": "home-made"}, ["gradient"]).results[0].available
115
+ assert not evaluate(SHALLOW_DYKE, {}, ["gradient"]).results[0].available # needs the location
116
+ with pytest.raises(ValueError):
117
+ evaluate(SHALLOW_DYKE, {**SITE, "gradiometer_baseline": 0}, ["gradient"])
@@ -1,7 +0,0 @@
1
- """Detectability: pre-survey detectability, modelled and reproducible."""
2
-
3
- __version__ = "0.2.1"
4
-
5
- from .survey import METHODS, SHAPES, Report, Survey, Target, evaluate # noqa: E402
6
-
7
- __all__ = ["evaluate", "Target", "Survey", "Report", "SHAPES", "METHODS", "__version__"]
File without changes
File without changes