detectability 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,404 @@
1
+ """Validation: every model against a result computed in a different way.
2
+
3
+ The same cases run as tests (each must stay within its tolerance), feed the website's Validation page, and print as a
4
+ Markdown table:
5
+
6
+ python -m detectability.validation
7
+
8
+ Errors are the largest absolute difference along the profile as a percentage of the largest reference value (or, for a
9
+ single value, the relative difference).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ import json
16
+ from dataclasses import dataclass
17
+ from importlib import resources
18
+
19
+ import numpy as np
20
+
21
+ from . import gravity, igrf, magnetics
22
+ from .constants import G, SI_TO_UGAL
23
+ from .magnetics import MU0, Ambient
24
+
25
+ MID = Ambient(48567.0, 58.373, 6.313) # Sivas, Türkiye, 2026 (IGRF-14)
26
+ POLE = Ambient(50000.0, 90.0, 0.0) # vertical field and magnetisation
27
+ CHI = 0.01
28
+
29
+ # British Geological Survey IGRF-14 web service (geomag.bgs.ac.uk), retrieved 3 Nov 2026; rounded to 1 nT and 0.001°.
30
+ BGS = [
31
+ ("Sivas, Türkiye", 39.75, 37.02, 0.0, "2026-07-01", 48567, 6.313, 58.373),
32
+ ("Cape Town, South Africa", -33.9, 18.4, 0.0, "2026-07-01", 24996, -26.673, -64.631),
33
+ ("Canadian Arctic", 70.0, -100.0, 0.0, "2026-07-01", 57831, -0.041, 85.147),
34
+ ("Amazon, 5 km altitude", 0.0, -60.0, 5.0, "2026-07-01", 26671, -16.84, 8.983),
35
+ ("London, United Kingdom", 51.5, -0.1, 0.0, "2012-03-15", 48635, -1.345, 66.469),
36
+ ]
37
+ IGRF_TOLERANCE_NT, IGRF_TOLERANCE_DEG = 1.0, 0.002
38
+
39
+ # Independent code: the same targets computed with Harmonica (Fatiando a Terra Project), a separate open-source package.
40
+ # tools/harmonica_reference.py computes them and stores data/harmonica_reference.json, so neither the library nor the
41
+ # website has to install Harmonica; CI installs it, recomputes the file and checks it. Profiles run north (azimuth 0),
42
+ # so the profile frame (along, across, down) is (north, east, down). A prism 200 km long stands in for a 2D dyke.
43
+ HARMONICA_FILE = "harmonica_reference.json"
44
+ _FIELD = (MID.total, MID.inclination, MID.declination)
45
+ HARMONICA_CASES = {
46
+ "harmonica-prism-gravity": {"source": "prism", "along": (-3.0, 3.0), "across": (-4.0, 4.0), "depth": (2.0, 6.0),
47
+ "drho": -2200.0, "x": (-40.0, 40.0, 81)},
48
+ "harmonica-prism-magnetic": {"source": "prism", "along": (-3.0, 2.0), "across": (-2.5, 3.5), "depth": (4.0, 7.0),
49
+ "chi": CHI, "field": _FIELD, "height": 0.5, "x": (-30.0, 30.0, 61)},
50
+ "harmonica-sphere-gravity": {"source": "sphere", "depth": 12.0, "radius": 3.0, "drho": -2600.0,
51
+ "x": (-60.0, 60.0, 121)},
52
+ "harmonica-sphere-magnetic": {"source": "sphere", "depth": 12.0, "radius": 3.0, "chi": CHI, "field": _FIELD,
53
+ "height": 0.5, "x": (-60.0, 60.0, 121)},
54
+ "harmonica-dyke-gravity": {"source": "prism", "along": (5.0, 9.0), "across": (-1e5, 1e5), "depth": (3.0, 15.0),
55
+ "drho": 500.0, "x": (-40.0, 40.0, 81)},
56
+ "harmonica-dyke-magnetic": {"source": "prism", "along": (-2.0, 2.0), "across": (-1e5, 1e5), "depth": (3.0, 20.0),
57
+ "chi": CHI, "field": _FIELD, "height": 0.5, "x": (-50.0, 50.0, 101)},
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class Case:
63
+ key: str
64
+ check: str # kind of check
65
+ geometry: str # target and method
66
+ model: str # how Detectability computes it
67
+ reference: str # the independent result
68
+ setting: str # the example used
69
+ units: str
70
+ tolerance: float # %: the test fails above this
71
+ error: float # %
72
+ x: np.ndarray | None = None
73
+ model_values: np.ndarray | None = None
74
+ reference_values: np.ndarray | None = None
75
+
76
+ @property
77
+ def passed(self) -> bool:
78
+ return self.error <= self.tolerance
79
+
80
+
81
+ def _profile_error(model, ref) -> float:
82
+ model, ref = np.asarray(model, float), np.asarray(ref, float)
83
+ return float(np.max(np.abs(model - ref)) / np.max(np.abs(ref)) * 100.0)
84
+
85
+
86
+ def _value_error(model: float, ref: float) -> float:
87
+ return float(abs(model - ref) / abs(ref) * 100.0)
88
+
89
+
90
+ def _point_masses(x, box, drho, n=16):
91
+ """Gauss-Legendre sum of point masses filling a box ((x1, x2), (y1, y2), (z1, z2)); profile along y = 0."""
92
+ nodes, weights = np.polynomial.legendre.leggauss(n)
93
+ pts = [((hi - lo) / 2 * nodes + (hi + lo) / 2, (hi - lo) / 2 * weights) for lo, hi in box]
94
+ X, Y, Z = np.meshgrid(pts[0][0], pts[1][0], pts[2][0], indexing="ij")
95
+ Wt = np.einsum("i,j,k->ijk", pts[0][1], pts[1][1], pts[2][1])
96
+ return np.array([np.sum(G * drho * Wt * Z / ((X - xi) ** 2 + Y**2 + Z**2) ** 1.5) for xi in x])
97
+
98
+
99
+ def _point_dipoles(x, box, chi, ambient, azimuth, height=0.0, n=16):
100
+ """Gauss-Legendre sum of point dipoles filling a box given in profile coordinates (along, across, depth)."""
101
+ nodes, weights = np.polynomial.legendre.leggauss(n)
102
+ pts = [((hi - lo) / 2 * nodes + (hi + lo) / 2, (hi - lo) / 2 * weights) for lo, hi in box]
103
+ az = np.radians(azimuth)
104
+ along, across = np.array([np.cos(az), np.sin(az), 0.0]), np.array([-np.sin(az), np.cos(az), 0.0])
105
+ m = magnetics.magnetisation(chi, ambient)
106
+ f = magnetics.unit_vector(ambient.inclination, ambient.declination)
107
+ A, C, Z = np.meshgrid(pts[0][0], pts[1][0], pts[2][0], indexing="ij")
108
+ Wt = np.einsum("i,j,k->ijk", pts[0][1], pts[1][1], pts[2][1]).ravel()
109
+ src = A.ravel()[:, None] * along + C.ravel()[:, None] * across + Z.ravel()[:, None] * np.array([0, 0, 1.0])
110
+ out = []
111
+ for xi in x:
112
+ r = xi * along - np.array([0, 0, height]) - src
113
+ dist = np.linalg.norm(r, axis=1, keepdims=True)
114
+ rhat = r / dist
115
+ b = MU0 / (4 * np.pi) * (3 * (rhat @ m)[:, None] * rhat - m) / dist**3
116
+ out.append(np.sum((b @ f) * Wt) * 1e9)
117
+ return np.array(out)
118
+
119
+
120
+ def _dipoles_along_strike(x, z, radius, chi, ambient, azimuth, n=20001):
121
+ m = magnetics.magnetisation(chi, ambient) * np.pi * radius**2
122
+ f = magnetics.unit_vector(ambient.inclination, ambient.declination)
123
+ a = np.radians(azimuth)
124
+ along, strike = np.array([np.cos(a), np.sin(a), 0.0]), np.array([-np.sin(a), np.cos(a), 0.0])
125
+ theta = np.linspace(-np.pi / 2, np.pi / 2, n)[1:-1]
126
+ s, ds = z * np.tan(theta), z / np.cos(theta) ** 2 * (theta[1] - theta[0])
127
+ out = []
128
+ for xi in x:
129
+ r = xi * along - s[:, None] * strike - np.array([0.0, 0.0, z])
130
+ dist = np.linalg.norm(r, axis=1, keepdims=True)
131
+ rhat = r / dist
132
+ b = MU0 / (4 * np.pi) * (3 * (rhat @ m)[:, None] * rhat - m) / dist**3
133
+ out.append(np.sum((b @ f) * ds) * 1e9)
134
+ return np.array(out)
135
+
136
+
137
+ def _disc_line_masses(x, radius, z1, z2, drho, n=200):
138
+ """Vertical cylinder by Gauss-Legendre over its cross-section in polar coordinates about the axis, each node a
139
+ vertical line mass in closed form; profile along y = 0. Independent of the rim integral used by the model."""
140
+ r, wr = np.polynomial.legendre.leggauss(n)
141
+ r, wr = radius / 2 * (r + 1), radius / 2 * wr
142
+ t, wt = np.polynomial.legendre.leggauss(2 * n)
143
+ t, wt = np.pi * (t + 1), np.pi * wt
144
+ rr, tt = np.meshgrid(r, t, indexing="ij")
145
+ w = np.outer(wr, wt) * rr
146
+ out = []
147
+ for xi in x:
148
+ s2 = (xi - rr * np.cos(tt)) ** 2 + (rr * np.sin(tt)) ** 2
149
+ out.append(np.sum(w * (1 / np.sqrt(s2 + z1**2) - 1 / np.sqrt(s2 + z2**2))))
150
+ return G * drho * np.array(out)
151
+
152
+
153
+ def _cylinder_dipoles(x, radius, z1, z2, chi, ambient, azimuth, height=0.0, n=32):
154
+ """Vertical cylinder filled with Gauss-Legendre point dipoles (polar about the axis, and depth)."""
155
+ r, wr = np.polynomial.legendre.leggauss(n)
156
+ r, wr = radius / 2 * (r + 1), radius / 2 * wr
157
+ t, wt = np.polynomial.legendre.leggauss(2 * n)
158
+ t, wt = np.pi * (t + 1), np.pi * wt
159
+ z, wz = np.polynomial.legendre.leggauss(n)
160
+ z, wz = (z2 - z1) / 2 * z + (z1 + z2) / 2, (z2 - z1) / 2 * wz
161
+ rr, tt, zz = np.meshgrid(r, t, z, indexing="ij")
162
+ w = (np.einsum("i,j,k->ijk", wr, wt, wz) * rr).ravel()
163
+ a = np.radians(azimuth)
164
+ along, across = np.array([np.cos(a), np.sin(a), 0.0]), np.array([-np.sin(a), np.cos(a), 0.0])
165
+ src = ((rr * np.cos(tt)).ravel()[:, None] * along + (rr * np.sin(tt)).ravel()[:, None] * across
166
+ + zz.ravel()[:, None] * np.array([0.0, 0.0, 1.0]))
167
+ m = magnetics.magnetisation(chi, ambient)
168
+ f = magnetics.unit_vector(ambient.inclination, ambient.declination)
169
+ out = []
170
+ for xi in x:
171
+ rel = xi * along - np.array([0.0, 0.0, height]) - src
172
+ dist = np.linalg.norm(rel, axis=1, keepdims=True)
173
+ rhat = rel / dist
174
+ b = MU0 / (4 * np.pi) * (3 * (rhat @ m)[:, None] * rhat - m) / dist**3
175
+ out.append(np.sum((b @ f) * w) * 1e9)
176
+ return np.array(out)
177
+
178
+
179
+ def _poisson(dgz_dz, chi, ambient, drho):
180
+ """Poisson's relation for vertical field and magnetisation: dT = mu0 M / (4 pi G drho) * dgz/dz, in nT."""
181
+ return MU0 * (chi * ambient.total * 1e-9 / MU0) / (4 * np.pi * G * drho) * dgz_dz * 1e9
182
+
183
+
184
+ def _dgz_dz(fn, top, bottom, d=1e-4):
185
+ """Vertical derivative of a gravity profile: moving the sensor down equals moving the body up."""
186
+ return (fn(top - d, bottom - d) - fn(top + d, bottom + d)) / (2 * d)
187
+
188
+
189
+ def _profile_case(key, check, geometry, model, reference, setting, units, tolerance, x, mv, rv) -> Case:
190
+ return Case(key, check, geometry, model, reference, setting, units, tolerance, _profile_error(mv, rv),
191
+ np.asarray(x, float), np.asarray(mv, float), np.asarray(rv, float))
192
+
193
+
194
+ def cases() -> list[Case]:
195
+ out: list[Case] = []
196
+
197
+ # --- analytical limits -----------------------------------------------------------------------------------
198
+ x = np.linspace(-300, 300, 121)
199
+ a, z = 2.0, 100.0
200
+ mass = (2 * a) ** 3 * -2600.0
201
+ out.append(_profile_case(
202
+ "prism-point-mass", "Analytical limit", "Prism, gravity, far from the sensor", "Nagy (1966) closed form",
203
+ "Point mass of the same mass", "4 m cube, centre at 100 m, −2,600 kg/m³", "µGal", 0.01, x,
204
+ gravity.prism(x, 0.0, -a, a, -a, a, z - a, z + a, -2600.0) * SI_TO_UGAL,
205
+ G * mass * z / (x**2 + z**2) ** 1.5 * SI_TO_UGAL))
206
+
207
+ t, L = 2.0, 1e5
208
+ out.append(Case("prism-slab", "Analytical limit", "Prism, gravity, wide and thin", "Nagy (1966) closed form",
209
+ "Infinite Bouguer slab, 2πGΔρt", "200 km wide, 2 m thick, top at 10 m, +1,000 kg/m³", "µGal", 0.1,
210
+ _value_error(gravity.prism(0.0, 0.0, -L, L, -L, L, 10.0, 10.0 + t, 1000.0),
211
+ 2 * np.pi * G * 1000.0 * t)))
212
+
213
+ out.append(Case("step-slab", "Analytical limit", "Vertical step, gravity, far over the slab", "Faulted-slab closed form",
214
+ "Infinite Bouguer slab, 2πGΔρt", "Slab from 5 to 25 m, +300 kg/m³, 1,000 km from the edge", "µGal",
215
+ 0.01, _value_error(gravity.vertical_step(1e6, 5.0, 25.0, 300.0), 2 * np.pi * G * 300.0 * 20.0)))
216
+
217
+ top, bottom, R = 5.0, 30.0, 3.0
218
+ exact = 2 * np.pi * G * -2600.0 * ((bottom - top) - np.hypot(bottom, R) + np.hypot(top, R))
219
+ out.append(Case("vertical-cylinder-axis", "Analytical limit", "Vertical cylinder, gravity, on its axis",
220
+ "Rim integral (closed form in depth and distance)", "Exact on-axis cylinder formula",
221
+ "Shaft of 6 m diameter from 5 to 30 m, −2,600 kg/m³", "µGal", 1e-10,
222
+ _value_error(gravity.vertical_cylinder(0.0, top, bottom, R, -2600.0), exact)))
223
+
224
+ # --- independent closed forms ----------------------------------------------------------------------------
225
+ x = np.linspace(-60, 60, 121)
226
+ z, R, n = 8.0, 2.0, 720
227
+ Rn = R * np.sqrt(2 * np.pi / (n * np.sin(2 * np.pi / n)))
228
+ ang = np.linspace(0, 2 * np.pi, n, endpoint=False)
229
+ circle = np.column_stack([Rn * np.cos(ang), z + Rn * np.sin(ang)])
230
+ out.append(_profile_case(
231
+ "cylinder-polygon", "Independent formula", "Horizontal cylinder, gravity", "Line-mass closed form",
232
+ "2D polygon, 720 sides (Talwani; Won & Bevis)", "Tunnel of 4 m diameter, axis at 8 m, −2,600 kg/m³", "µGal",
233
+ 0.01, x, gravity.horizontal_cylinder(x, z, R, -2600.0) * SI_TO_UGAL,
234
+ gravity.polygon(x, circle, -2600.0) * SI_TO_UGAL))
235
+
236
+ x = np.linspace(-40, 40, 81)
237
+ out.append(_profile_case(
238
+ "dyke-polygon", "Independent formula", "Vertical dyke, gravity", "Difference of two vertical steps",
239
+ "2D polygon (Won & Bevis)", "4 m wide dyke from 3 to 15 m, +500 kg/m³", "µGal", 1e-6, x,
240
+ gravity.dyke(x, 3, 15, 4, 500.0, centre=7) * SI_TO_UGAL,
241
+ gravity.polygon(x, [(5, 3), (9, 3), (9, 15), (5, 15)], 500.0) * SI_TO_UGAL))
242
+
243
+ # --- numerical integration -------------------------------------------------------------------------------
244
+ x = np.linspace(-40, 40, 81)
245
+ box = ((-3.0, 3.0), (-4.0, 4.0), (2.0, 6.0))
246
+ out.append(_profile_case(
247
+ "prism-gravity-quadrature", "Numerical integration", "Prism, gravity", "Nagy (1966) closed form",
248
+ "16³ Gauss–Legendre point masses", "6 × 8 m room from 2 to 6 m, −2,200 kg/m³", "µGal", 0.001, x,
249
+ gravity.prism(x, 0.0, *box[0], *box[1], *box[2], -2200.0) * SI_TO_UGAL,
250
+ _point_masses(x, box, -2200.0) * SI_TO_UGAL))
251
+
252
+ x = np.linspace(-40, 40, 41)
253
+ out.append(_profile_case(
254
+ "cylinder-magnetic-strike", "Numerical integration", "Horizontal cylinder, magnetic", "2D line dipole",
255
+ "3D dipoles integrated along strike", "Pipe of 2 m diameter, axis at 6 m, 0.01 SI, main field of Sivas, "
256
+ "profile azimuth 30°", "nT", 0.01, x, magnetics.horizontal_cylinder(x, 6.0, 1.0, CHI, MID, azimuth=30.0),
257
+ _dipoles_along_strike(x, 6.0, 1.0, CHI, MID, 30.0)))
258
+
259
+ x = np.linspace(-30, 30, 61)
260
+ box = ((-3.0, 2.0), (-2.5, 3.5), (4.0, 7.0))
261
+ out.append(_profile_case(
262
+ "prism-magnetic-quadrature", "Numerical integration", "Prism, magnetic",
263
+ "Gradient tensor and Poisson's relation", "16³ Gauss–Legendre point dipoles",
264
+ "5 × 6 m block from 4 to 7 m, 0.01 SI, sensor 0.5 m above ground, azimuth 30°", "nT", 0.001, x,
265
+ magnetics.prism(x, *box[0], *box[1], *box[2], CHI, MID, azimuth=30.0, height=0.5),
266
+ _point_dipoles(x, box, CHI, MID, 30.0, height=0.5)))
267
+
268
+ x = np.linspace(-30, 30, 61)
269
+ out.append(_profile_case(
270
+ "vertical-cylinder-quadrature", "Numerical integration", "Vertical cylinder, gravity",
271
+ "Rim integral (closed form in depth and distance)", "Gauss–Legendre over the cross-section, line masses in depth",
272
+ "Shaft of 6 m diameter from 5 to 30 m, −2,600 kg/m³", "µGal", 1e-8, x,
273
+ gravity.vertical_cylinder(x, 5.0, 30.0, 3.0, -2600.0) * SI_TO_UGAL,
274
+ _disc_line_masses(x, 3.0, 5.0, 30.0, -2600.0) * SI_TO_UGAL))
275
+
276
+ out.append(_profile_case(
277
+ "vertical-cylinder-magnetic-quadrature", "Numerical integration", "Vertical cylinder, magnetic",
278
+ "Gradient tensor (rim integral) and Poisson's relation", "Gauss–Legendre point dipoles over the volume",
279
+ "Shaft of 6 m diameter from 5 to 30 m, 0.01 SI, sensor 0.5 m above ground, azimuth 30°", "nT", 1e-6, x,
280
+ magnetics.vertical_cylinder(x, 5.0, 30.0, 3.0, CHI, MID, azimuth=30.0, height=0.5),
281
+ _cylinder_dipoles(x, 3.0, 5.5, 30.5, CHI, MID, 30.0)))
282
+
283
+ # --- Poisson's relation: the magnetic codes against the independent gravity codes -------------------------
284
+ x = np.linspace(-60, 60, 121)
285
+ out.append(_profile_case(
286
+ "sphere-poisson", "Poisson's relation", "Sphere, gravity ↔ magnetic", "Magnetic dipole",
287
+ "Vertical derivative of the gravity sphere", "Radius 3 m, centre at 12 m, at the pole", "nT", 0.001, x,
288
+ magnetics.sphere(x, 12.0, 3.0, CHI, POLE),
289
+ _poisson(_dgz_dz(lambda a, b: gravity.sphere(x, a, 3.0, 1000.0), 12.0, 12.0) , CHI, POLE, 1000.0)))
290
+
291
+ x = np.linspace(-50, 50, 101)
292
+ for key, geometry, dip in (("dyke-poisson", "Vertical dyke, gravity ↔ magnetic", 90.0),
293
+ ("dipping-dyke-poisson", "Dipping dyke (60°), gravity ↔ magnetic", 60.0)):
294
+ out.append(_profile_case(
295
+ key, "Poisson's relation", geometry, "Magnetic 2D polygon (surface charge)",
296
+ "Vertical derivative of the gravity dyke", "4 m wide, from 3 to 20 m, at the pole", "nT", 1e-4, x,
297
+ magnetics.dyke(x, 3.0, 20.0, 4.0, CHI, POLE, dip=dip),
298
+ _poisson(_dgz_dz(lambda a, b: gravity.dyke(x, a, b, 4.0, 1000.0, dip=dip), 3.0, 20.0), CHI, POLE, 1000.0)))
299
+
300
+ x = np.linspace(-40, 40, 81)
301
+ out.append(_profile_case(
302
+ "vertical-cylinder-poisson", "Poisson's relation", "Vertical cylinder, gravity ↔ magnetic",
303
+ "Gradient tensor (rim integral)", "Vertical derivative of the gravity cylinder",
304
+ "Shaft of 6 m diameter from 2 to 30 m, at the pole", "nT", 1e-4, x,
305
+ magnetics.vertical_cylinder(x, 2.0, 30.0, 3.0, CHI, POLE),
306
+ _poisson(_dgz_dz(lambda a, b: gravity.vertical_cylinder(x, a, b, 3.0, 1000.0), 2.0, 30.0), CHI, POLE, 1000.0)))
307
+
308
+ x = np.linspace(-80, 80, 161)
309
+ out.append(_profile_case(
310
+ "contact-poisson", "Poisson's relation", "Vertical contact, gravity ↔ magnetic", "Magnetic 2D polygon",
311
+ "Vertical derivative of the gravity step", "Slab from 5 to 40 m, at the pole", "nT", 1e-4, x,
312
+ magnetics.contact(x, 5.0, 40.0, CHI, POLE),
313
+ _poisson(_dgz_dz(lambda a, b: gravity.vertical_step(x, a, b, 1000.0), 5.0, 40.0), CHI, POLE, 1000.0)))
314
+
315
+ # --- independent code ---------------------------------------------------------------------------------------
316
+ out.extend(_harmonica_cases())
317
+ return out
318
+
319
+
320
+ def harmonica_reference() -> dict | None:
321
+ """The stored Harmonica values and the versions they were computed with (None if the file is missing)."""
322
+ try:
323
+ return json.loads(resources.files("detectability").joinpath("data", HARMONICA_FILE).read_text("utf-8"))
324
+ except FileNotFoundError:
325
+ return None
326
+
327
+
328
+ def _harmonica_cases() -> list[Case]:
329
+ ref = harmonica_reference()
330
+ if ref is None:
331
+ return []
332
+ h = f"Harmonica {ref['harmonica'].lstrip('v')}"
333
+ rows = {
334
+ "harmonica-prism-gravity": ("Prism, gravity", "Nagy (1966) closed form", f"{h} prism_gravity",
335
+ "6 × 8 m room from 2 to 6 m, −2,200 kg/m³", "µGal", 1e-8),
336
+ "harmonica-prism-magnetic": ("Prism, magnetic", "Gradient tensor and Poisson's relation", f"{h} prism_magnetic",
337
+ "5 × 6 m block from 4 to 7 m, 0.01 SI, main field of Sivas, sensor 0.5 m above "
338
+ "ground", "nT", 1e-8),
339
+ "harmonica-sphere-gravity": ("Sphere, gravity", "Point mass", f"{h} point_gravity",
340
+ "Radius 3 m, centre at 12 m, −2,600 kg/m³", "µGal", 1e-10),
341
+ "harmonica-sphere-magnetic": ("Sphere, magnetic", "Magnetic dipole", f"{h} dipole_magnetic",
342
+ "Radius 3 m, centre at 12 m, 0.01 SI, main field of Sivas, sensor 0.5 m above "
343
+ "ground", "nT", 1e-10),
344
+ "harmonica-dyke-gravity": ("Vertical dyke, gravity", "Difference of two vertical steps (2D)",
345
+ f"{h} prism_gravity, 200 km along strike", "4 m wide dyke from 3 to 15 m, "
346
+ "+500 kg/m³", "µGal", 1e-5),
347
+ "harmonica-dyke-magnetic": ("Vertical dyke, magnetic", "2D polygon (surface charge)",
348
+ f"{h} prism_magnetic, 200 km along strike", "4 m wide, from 3 to 20 m, 0.01 SI, "
349
+ "main field of Sivas, sensor 0.5 m above ground", "nT", 1e-5),
350
+ }
351
+ amb = Ambient(*_FIELD)
352
+ out = []
353
+ for key, c in HARMONICA_CASES.items():
354
+ x = np.linspace(c["x"][0], c["x"][1], int(c["x"][2]))
355
+ if key == "harmonica-prism-gravity":
356
+ model = gravity.prism(x, 0.0, *c["along"], *c["across"], *c["depth"], c["drho"]) * SI_TO_UGAL
357
+ elif key == "harmonica-prism-magnetic":
358
+ model = magnetics.prism(x, *c["along"], *c["across"], *c["depth"], c["chi"], amb, height=c["height"])
359
+ elif key == "harmonica-sphere-gravity":
360
+ model = gravity.sphere(x, c["depth"], c["radius"], c["drho"]) * SI_TO_UGAL
361
+ elif key == "harmonica-sphere-magnetic":
362
+ model = magnetics.sphere(x, c["depth"], c["radius"], c["chi"], amb, height=c["height"])
363
+ elif key == "harmonica-dyke-gravity":
364
+ width = c["along"][1] - c["along"][0]
365
+ model = gravity.dyke(x, *c["depth"], width, c["drho"], centre=sum(c["along"]) / 2) * SI_TO_UGAL
366
+ else:
367
+ width = c["along"][1] - c["along"][0]
368
+ model = magnetics.dyke(x, *c["depth"], width, c["chi"], amb, centre=sum(c["along"]) / 2,
369
+ height=c["height"])
370
+ geometry, how, reference, setting, units, tol = rows[key]
371
+ out.append(_profile_case(key, "Independent code", geometry, how, reference, setting, units, tol, x, model,
372
+ np.asarray(ref["cases"][key], float)))
373
+ return out
374
+
375
+
376
+ def igrf_rows() -> list[dict]:
377
+ rows = []
378
+ for site, lat, lon, alt, date, F, D, I in BGS:
379
+ f = igrf.field(lat, lon, _dt.date.fromisoformat(date), alt)
380
+ rows.append({"site": site, "date": date, "F": F, "D": D, "I": I, "F_model": f.total,
381
+ "D_model": f.declination, "I_model": f.inclination,
382
+ "dF": f.total - F, "dD": f.declination - D, "dI": f.inclination - I})
383
+ return rows
384
+
385
+
386
+ def _pct(v: float) -> str:
387
+ return f"{v:.1e} %" if v < 0.001 else f"{v:.3g} %"
388
+
389
+
390
+ def markdown_table() -> str:
391
+ lines = ["| Check | Geometry and method | Reference | Max. relative error | Tolerance |",
392
+ "|---|---|---|---:|---:|"]
393
+ for c in cases():
394
+ lines.append(f"| {c.check} | {c.geometry} | {c.reference} | {_pct(c.error)} | {_pct(c.tolerance)} |")
395
+ rows = igrf_rows()
396
+ dF = max(abs(r["dF"]) for r in rows)
397
+ dA = max(max(abs(r["dD"]), abs(r["dI"])) for r in rows)
398
+ lines.append(f"| Reference service | IGRF-14 main field, 5 sites | British Geological Survey IGRF-14 | "
399
+ f"{dF:.2f} nT, {dA:.4f}° | {IGRF_TOLERANCE_NT:g} nT, {IGRF_TOLERANCE_DEG:g}° |")
400
+ return "\n".join(lines)
401
+
402
+
403
+ if __name__ == "__main__":
404
+ print(markdown_table())
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: detectability
3
+ Version: 0.2.1
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
+ Author: Mükremin Nergiz
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://detectability.mukreminnergiz.com
8
+ Project-URL: Documentation, https://detectability.mukreminnergiz.com/method
9
+ Project-URL: Validation, https://detectability.mukreminnergiz.com/validation
10
+ Project-URL: Repository, https://github.com/mukremin-nergiz/detectability
11
+ Project-URL: Issues, https://github.com/mukremin-nergiz/detectability/issues
12
+ Project-URL: Citation, https://doi.org/10.5281/zenodo.23931815
13
+ Keywords: geophysics,gravity,magnetics,survey design,near-surface,forward modelling,IGRF
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Intended Audience :: Education
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Physics
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: numpy>=1.24
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest>=7; extra == "test"
30
+ Provides-Extra: validation
31
+ Requires-Dist: harmonica==0.7.0; extra == "validation"
32
+ Dynamic: license-file
33
+
34
+ # Detectability
35
+
36
+ [![tests](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml/badge.svg)](https://github.com/mukremin-nergiz/detectability/actions/workflows/tests.yml)
37
+ [![licence: BSD-3-Clause](https://img.shields.io/badge/licence-BSD--3--Clause-blue.svg)](https://github.com/mukremin-nergiz/detectability/blob/main/LICENSE)
38
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23931815.svg)](https://doi.org/10.5281/zenodo.23931815)
39
+
40
+ **Pre-survey detectability, modelled and reproducible.** Describe a buried target and a survey. Detectability models the gravity and
41
+ magnetic response of the target, compares it with instrument and field noise, and gives a verdict for each method
42
+ (clearly detectable, marginal, not detectable), with the recommended station spacing and profile length, every
43
+ assumption, and a Python script that reproduces the result.
44
+
45
+ - Web tool: <https://detectability.mukreminnergiz.com>
46
+ - Method and validation: <https://detectability.mukreminnergiz.com/method>
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install detectability
52
+ ```
53
+
54
+ Python 3.10 or newer; the only dependency is NumPy. The development version: `pip install "git+https://github.com/mukremin-nergiz/detectability"`.
55
+
56
+ ## Example
57
+
58
+ A 20 m air-filled cave whose top is 15 m deep in limestone, surveyed near Sivas, Türkiye:
59
+
60
+ ```python
61
+ from detectability import evaluate
62
+
63
+ report = evaluate(
64
+ {"shape": "sphere", "depth_top": 15, "size": 20, "material": "air", "host": "limestone"},
65
+ {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"},
66
+ )
67
+ print(report.summary())
68
+ ```
69
+
70
+ ```text
71
+ Detectability 0.2.1: sphere in limestone
72
+ - gravity: peak 114 µGal, noise 11.2 µGal, S/N 10.2: clearly detectable
73
+ stations every 5 m or less, profile at least 153 m long
74
+ - magnetic: peak 0.462 nT, noise 2 nT, S/N 0.231: not detectable
75
+ Recommended method: gravity
76
+ ```
77
+
78
+ `report.script()` returns a standalone script that recomputes the report; `report.to_dict()` gives the full result,
79
+ including the modelled profiles and the assumptions.
80
+
81
+ Target shapes: `sphere`, `horizontal_cylinder`, `vertical_cylinder`, `prism`, `dyke` (any dip) and `contact`. The main
82
+ geomagnetic field comes from IGRF-14 for the survey location and date. Typical densities and susceptibilities are built
83
+ in, and measured contrasts can be given instead.
84
+
85
+ ## Modules
86
+
87
+ | Module | Contents |
88
+ |---|---|
89
+ | `survey` | `evaluate()`: target and survey in, report and reproduction script out |
90
+ | `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 |
92
+ | `igrf` | IGRF-14 main field from location and date, from the official IAGA coefficients |
93
+ | `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 |
95
+
96
+ ## Validation
97
+
98
+ Every model is checked against a result computed in a different way. Each row below is also a test: it fails if the
99
+ error exceeds the tolerance. The error is the largest difference along the profile as a percentage of the largest
100
+ reference value. Figures for each row: <https://detectability.mukreminnergiz.com/validation>.
101
+
102
+ The "Independent code" rows compare with [Harmonica](https://www.fatiando.org/harmonica) 0.7.0 (Fatiando a Terra
103
+ Project, [doi:10.5281/zenodo.13308312](https://doi.org/10.5281/zenodo.13308312)), a separate open-source package. Its
104
+ values are stored in `src/detectability/data/harmonica_reference.json`, so the library does not depend on it; to
105
+ recompute and check them:
106
+
107
+ ```bash
108
+ pip install -e ".[validation]"
109
+ python tools/harmonica_reference.py --check
110
+ ```
111
+
112
+ | Check | Geometry and method | Reference | Max. relative error | Tolerance |
113
+ |---|---|---|---:|---:|
114
+ | Analytical limit | Prism, gravity, far from the sensor | Point mass of the same mass | 1.9e-05 % | 0.01 % |
115
+ | Analytical limit | Prism, gravity, wide and thin | Infinite Bouguer slab, 2πGΔρt | 0.0099 % | 0.1 % |
116
+ | Analytical limit | Vertical step, gravity, far over the slab | Infinite Bouguer slab, 2πGΔρt | 4.8e-04 % | 0.01 % |
117
+ | Analytical limit | Vertical cylinder, gravity, on its axis | Exact on-axis cylinder formula | 1.7e-13 % | 1.0e-10 % |
118
+ | Independent formula | Horizontal cylinder, gravity | 2D polygon, 720 sides (Talwani; Won & Bevis) | 6.3e-12 % | 0.01 % |
119
+ | Independent formula | Vertical dyke, gravity | 2D polygon (Won & Bevis) | 1.9e-13 % | 1.0e-06 % |
120
+ | Numerical integration | Prism, gravity | 16³ Gauss–Legendre point masses | 2.9e-06 % | 0.001 % |
121
+ | Numerical integration | Horizontal cylinder, magnetic | 3D dipoles integrated along strike | 2.2e-07 % | 0.01 % |
122
+ | Numerical integration | Prism, magnetic | 16³ Gauss–Legendre point dipoles | 1.3e-12 % | 0.001 % |
123
+ | Numerical integration | Vertical cylinder, gravity | Gauss–Legendre over the cross-section, line masses in depth | 5.5e-13 % | 1.0e-08 % |
124
+ | Numerical integration | Vertical cylinder, magnetic | Gauss–Legendre point dipoles over the volume | 5.8e-13 % | 1.0e-06 % |
125
+ | Poisson's relation | Sphere, gravity ↔ magnetic | Vertical derivative of the gravity sphere | 1.4e-08 % | 0.001 % |
126
+ | Poisson's relation | Vertical dyke, gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.2e-08 % | 1.0e-04 % |
127
+ | Poisson's relation | Dipping dyke (60°), gravity ↔ magnetic | Vertical derivative of the gravity dyke | 2.6e-08 % | 1.0e-04 % |
128
+ | Poisson's relation | Vertical cylinder, gravity ↔ magnetic | Vertical derivative of the gravity cylinder | 3.6e-08 % | 1.0e-04 % |
129
+ | Poisson's relation | Vertical contact, gravity ↔ magnetic | Vertical derivative of the gravity step | 3.9e-06 % | 1.0e-04 % |
130
+ | Independent code | Prism, gravity | Harmonica 0.7.0 prism_gravity | 3.0e-12 % | 1.0e-08 % |
131
+ | Independent code | Prism, magnetic | Harmonica 0.7.0 prism_magnetic | 4.8e-13 % | 1.0e-08 % |
132
+ | Independent code | Sphere, gravity | Harmonica 0.7.0 point_gravity | 3.9e-14 % | 1.0e-10 % |
133
+ | Independent code | Sphere, magnetic | Harmonica 0.7.0 dipole_magnetic | 8.8e-14 % | 1.0e-10 % |
134
+ | Independent code | Vertical dyke, gravity | Harmonica 0.7.0 prism_gravity, 200 km along strike | 3.6e-07 % | 1.0e-05 % |
135
+ | Independent code | Vertical dyke, magnetic | Harmonica 0.7.0 prism_magnetic, 200 km along strike | 5.1e-07 % | 1.0e-05 % |
136
+ | Reference service | IGRF-14 main field, 5 sites | British Geological Survey IGRF-14 | 0.38 nT, 0.0005° | 1 nT, 0.002° |
137
+
138
+ Regenerate this table with `python -m detectability.validation`. Run all tests (including these) with:
139
+
140
+ ```bash
141
+ pip install -e ".[test]"
142
+ pytest
143
+ ```
144
+
145
+ ## Limitations
146
+
147
+ Flat topography, a homogeneous host and no regional trend; 2D targets are infinitely long along strike; the profile
148
+ crosses the centre of the target; self-demagnetisation is neglected. Results are expectations under these assumptions,
149
+ 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.
151
+
152
+ ## Citation
153
+
154
+ See [CITATION.cff](https://github.com/mukremin-nergiz/detectability/blob/main/CITATION.cff), or:
155
+
156
+ > Nergiz, M. (2026). *Detectability: Pre-survey detectability, modelled and reproducible* (Version 0.2.1) [Computer software].
157
+ > Zenodo. https://doi.org/10.5281/zenodo.23931815
158
+
159
+ The DOI above always resolves to the latest version; each release also has its own DOI on Zenodo.
160
+
161
+ ## Author
162
+
163
+ Mükremin Nergiz, Geophysical Engineer & Software Developer (Independent Researcher, Sivas, Türkiye).
164
+ ORCID [0009-0001-6789-2774](https://orcid.org/0009-0001-6789-2774) · <https://mukreminnergiz.com>
165
+
166
+ Licence: BSD-3-Clause.
@@ -0,0 +1,16 @@
1
+ detectability/__init__.py,sha256=cO_ngIky4b0Un6TiEtLHGTiLX0OUZRE_Ht9XJsJE3Zw,272
2
+ detectability/constants.py,sha256=t4Le7jC1QFbG5LJZ6h9eWn5FAiarX6HBzEv2DbaoQHw,277
3
+ detectability/detect.py,sha256=Ncs8ISd8_OiVkRA1HP7sO5Ct07Fw65AIlt3AjzyntXc,7415
4
+ detectability/gravity.py,sha256=f6c4x79L4bGobn9XNpRhFY3ymZy_ari2s8HweH8xfAQ,14364
5
+ detectability/igrf.py,sha256=BZWUGSR-Wg6PNVopdg764Nuqb74maDpsEFL8SXSNsZE,6889
6
+ detectability/magnetics.py,sha256=RfIBMSvNDMj92QNq4NrRsg0y_s5Sdl9NXR9Hr92Nuo4,10780
7
+ detectability/rocks.py,sha256=Vb5JuiSEUSAZqry00bj8vo2Ee5meUOePv__MjPAjQtE,2823
8
+ detectability/survey.py,sha256=aNzuSRolAu0NmMyBAa_L7nJ8iRzekWjHOVk1JlWkj2g,15063
9
+ detectability/validation.py,sha256=OxdQ8DnQ37nsnmtSPIt7NClBkF0ijwED5Rt0PFbj1lA,22315
10
+ detectability/data/harmonica_reference.json,sha256=rvofao_ePp7csTT_F7TkPbCW1lN4ua-MKxzZ9iNRY9o,13919
11
+ detectability/data/igrf14coeffs.txt,sha256=j42IQDAo_E7pLE842XtG4Kh-LPxJYEW0PJ4mwdawkDw,42411
12
+ detectability-0.2.1.dist-info/licenses/LICENSE,sha256=JhugoJkggCbcANfpdnaa_eXT5lgZxlxRlwvV_jQygH0,1503
13
+ detectability-0.2.1.dist-info/METADATA,sha256=zPSOJN0Y0Co6888hfZD7C4-EYMtmpSt-whtZe7Uqtok,9551
14
+ detectability-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
15
+ detectability-0.2.1.dist-info/top_level.txt,sha256=Xs154_L3v581zFA1ogt3Lkfw_3HIFHAKnykT-Na4JI4,14
16
+ detectability-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Mükremin Nergiz
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ detectability