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.
detectability/rocks.py ADDED
@@ -0,0 +1,68 @@
1
+ """Typical physical properties of near-surface materials: density (g/cm^3) and magnetic susceptibility (SI), each as
2
+ (typical, low, high).
3
+
4
+ Sources: Telford, Geldart & Sheriff (1990), Applied Geophysics, 2nd ed., Table 6.1 (densities) and Table 3.3
5
+ (susceptibilities), rounded. Values vary widely with porosity, saturation, weathering and magnetite content; use site data
6
+ when available. Verify against the source before the v1 release.
7
+ """
8
+
9
+ DENSITY_G_CM3: dict[str, tuple[float, float, float]] = {
10
+ # name: (typical, low, high)
11
+ "air": (0.0, 0.0, 0.0),
12
+ "water": (1.0, 1.0, 1.05),
13
+ "soil": (1.92, 1.2, 2.4),
14
+ "clay": (2.21, 1.63, 2.6),
15
+ "sand": (2.0, 1.7, 2.3),
16
+ "gravel": (2.0, 1.7, 2.4),
17
+ "sandstone": (2.35, 1.61, 2.76),
18
+ "shale": (2.40, 1.77, 3.2),
19
+ "limestone": (2.55, 1.93, 2.90),
20
+ "dolomite": (2.70, 2.28, 2.90),
21
+ "marble": (2.75, 2.6, 2.9),
22
+ "rock salt": (2.22, 2.10, 2.60),
23
+ "gypsum": (2.35, 2.2, 2.6),
24
+ "granite": (2.64, 2.50, 2.81),
25
+ "basalt": (2.99, 2.70, 3.30),
26
+ "gabbro": (3.03, 2.70, 3.50),
27
+ }
28
+
29
+
30
+ # Telford et al. Table 3.3 gives susceptibility x 10^3 (SI); stored here in SI. Where the table gives only an average,
31
+ # low and high repeat it. Air and water are effectively zero (water is weakly diamagnetic, about -9e-6).
32
+ SUSCEPTIBILITY_SI: dict[str, tuple[float, float, float]] = {
33
+ "air": (0.0, 0.0, 0.0),
34
+ "water": (0.0, 0.0, 0.0),
35
+ "clay": (0.2e-3, 0.2e-3, 0.2e-3),
36
+ "dolomite": (0.1e-3, 0.0, 0.9e-3),
37
+ "limestone": (0.3e-3, 0.0, 3e-3),
38
+ "sandstone": (0.4e-3, 0.0, 20e-3),
39
+ "shale": (0.6e-3, 0.01e-3, 15e-3),
40
+ "rock salt": (-0.01e-3, -0.01e-3, -0.01e-3),
41
+ "gypsum": (-0.01e-3, -0.01e-3, -0.01e-3),
42
+ "slate": (6e-3, 0.0, 35e-3),
43
+ "schist": (1.4e-3, 0.3e-3, 3e-3),
44
+ "granite": (2.5e-3, 0.0, 50e-3),
45
+ "diorite": (85e-3, 0.6e-3, 120e-3),
46
+ "gabbro": (70e-3, 1e-3, 90e-3),
47
+ "basalt": (70e-3, 0.2e-3, 175e-3),
48
+ "andesite": (160e-3, 160e-3, 160e-3),
49
+ "peridotite": (150e-3, 90e-3, 200e-3),
50
+ "magnetite": (6.0, 1.2, 19.2),
51
+ }
52
+
53
+
54
+ def _typical(table: dict, name: str, what: str) -> float:
55
+ try:
56
+ return table[name][0]
57
+ except KeyError:
58
+ raise KeyError(f"no {what} for {name!r}; known: {', '.join(sorted(table))}") from None
59
+
60
+
61
+ def contrast(target: str, host: str) -> float:
62
+ """Density contrast target - host in kg/m^3 from the typical values (e.g. air-filled void in limestone: -2550)."""
63
+ return (_typical(DENSITY_G_CM3, target, "density") - _typical(DENSITY_G_CM3, host, "density")) * 1000.0
64
+
65
+
66
+ def susceptibility_contrast(target: str, host: str) -> float:
67
+ """Susceptibility contrast target - host in SI from the typical values (e.g. void in limestone: -3e-4)."""
68
+ return _typical(SUSCEPTIBILITY_SI, target, "susceptibility") - _typical(SUSCEPTIBILITY_SI, host, "susceptibility")
@@ -0,0 +1,323 @@
1
+ """One question, one answer: describe a target and a survey, get the modelled response and a detectability assessment
2
+ for each method, with every input needed to reproduce it.
3
+
4
+ >>> from detectability import evaluate
5
+ >>> report = evaluate({"shape": "sphere", "depth_top": 15, "size": 20, "material": "air", "host": "limestone"},
6
+ ... {"latitude": 39.75, "longitude": 37.02, "date": "2026-07-01"})
7
+ >>> print(report.summary())
8
+
9
+ The website, the ChatGPT/Claude app and the generated scripts all call this function, so they give the same numbers.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ import json
16
+ from dataclasses import asdict, dataclass, field, fields, replace
17
+
18
+ import numpy as np
19
+
20
+ from . import __version__, detect, gravity, igrf, magnetics, rocks
21
+ from .constants import SI_TO_UGAL
22
+
23
+ METHODS = ("gravity", "magnetic")
24
+
25
+ SHAPES: dict[str, str] = {
26
+ "sphere": "Sphere (cavity, boulder, buried object). size = diameter.",
27
+ "horizontal_cylinder": "Horizontal cylinder crossing the profile at right angles (tunnel, pipe, gallery). size = diameter.",
28
+ "vertical_cylinder": "Vertical cylinder (shaft, well, sinkhole pipe). size = diameter; needs depth_bottom.",
29
+ "prism": "Rectangular block (room, foundation, tank). size = width along the profile, length = extent across it "
30
+ "(default: size); needs depth_bottom.",
31
+ "dyke": "Dyke, wall or filled trench crossing the profile. size = width; needs depth_bottom; optional dip.",
32
+ "contact": "Vertical contact (fault, edge of a fill or of a rock unit); the target material lies on the +x side. "
33
+ "Needs depth_bottom; size is not used.",
34
+ }
35
+ NEEDS_BOTTOM = {"vertical_cylinder", "prism", "dyke", "contact"}
36
+ NEEDS_SIZE = set(SHAPES) - {"contact"}
37
+ TWO_D = {"horizontal_cylinder", "dyke", "contact"}
38
+
39
+
40
+ @dataclass
41
+ class Target:
42
+ shape: str
43
+ depth_top: float # m below ground to the top of the target
44
+ size: float | None = None # m, see SHAPES
45
+ depth_bottom: float | None = None # m, see NEEDS_BOTTOM
46
+ length: float | None = None # m, prism extent across the profile
47
+ dip: float = 90.0 # degrees from horizontal, dyke only
48
+ material: str = "air" # what the target is made of (fill), see rocks
49
+ host: str = "limestone" # surrounding material
50
+ density_contrast: float | None = None # kg/m^3, overrides the material tables
51
+ susceptibility_contrast: float | None = None # SI, overrides the material tables
52
+
53
+ def validate(self) -> None:
54
+ if self.shape not in SHAPES:
55
+ raise ValueError(f"unknown shape {self.shape!r}; choose from {', '.join(SHAPES)}")
56
+ if self.depth_top < 0:
57
+ raise ValueError("depth_top must be zero or positive")
58
+ if self.shape in NEEDS_SIZE and not (self.size and self.size > 0):
59
+ raise ValueError(f"{self.shape} needs a positive size")
60
+ if self.shape in NEEDS_BOTTOM and not (self.depth_bottom and self.depth_bottom > self.depth_top):
61
+ raise ValueError(f"{self.shape} needs depth_bottom greater than depth_top")
62
+ if self.shape in ("sphere", "horizontal_cylinder") and self.depth_top <= 0:
63
+ raise ValueError(f"{self.shape} must be buried: depth_top > 0")
64
+
65
+
66
+ @dataclass
67
+ class Survey:
68
+ gravimeter: str = "Scintrex CG-6"
69
+ gravity_field_noise: float = detect.DEFAULT_FIELD_NOISE_UGAL # µGal
70
+ gravity_instrument_noise: float | None = None # µGal, overrides the gravimeter preset
71
+ magnetometer: str = "Overhauser"
72
+ magnetic_field_noise: float = detect.DEFAULT_FIELD_NOISE_NT # nT
73
+ magnetic_instrument_noise: float | None = None # nT, overrides the magnetometer preset
74
+ sensor_height: float = 0.5 # m, magnetometer sensor above ground
75
+ profile_azimuth: float = 0.0 # degrees east of north; 2D targets strike at right angles to it
76
+ latitude: float | None = None # for the main field (IGRF-14); needed for magnetics
77
+ longitude: float | None = None
78
+ date: str | None = None # ISO date of the survey; default: today
79
+ field_total: float | None = None # nT; main field given directly instead of IGRF (all three together)
80
+ field_inclination: float | None = None
81
+ field_declination: float | None = None
82
+
83
+
84
+ @dataclass
85
+ class MethodResult:
86
+ method: str
87
+ available: bool
88
+ reason: str = ""
89
+ contrast: float | None = None
90
+ contrast_units: str = ""
91
+ instrument: str = ""
92
+ instrument_noise: float | None = None
93
+ field_noise: float | None = None
94
+ main_field: dict | None = None
95
+ x: list[float] = field(default_factory=list)
96
+ anomaly: list[float] = field(default_factory=list)
97
+ assessment: detect.Assessment | None = None
98
+
99
+
100
+ @dataclass
101
+ class Report:
102
+ version: str
103
+ target: dict
104
+ survey: dict
105
+ methods: list[str]
106
+ results: list[MethodResult]
107
+ recommended: str | None
108
+
109
+ def result(self, method: str) -> MethodResult:
110
+ return next(r for r in self.results if r.method == method)
111
+
112
+ def to_dict(self, profile: bool = True) -> dict:
113
+ d = asdict(self)
114
+ if not profile:
115
+ for r in d["results"]:
116
+ r.pop("x"), r.pop("anomaly")
117
+ return d
118
+
119
+ def summary(self) -> str:
120
+ lines = [f"Detectability {self.version}: {self.target['shape']} in {self.target['host']}"]
121
+ for r in self.results:
122
+ if not r.available:
123
+ lines.append(f"- {r.method}: not assessed ({r.reason})")
124
+ continue
125
+ a = r.assessment
126
+ lines.append(f"- {r.method}: peak {a.peak:.3g} {a.units}, noise {a.noise:.3g} {a.units}, "
127
+ f"S/N {a.snr:.3g}: {a.verdict}")
128
+ if a.verdict != "not detectable":
129
+ lines.append(f" stations every {a.station_spacing:g} m or less, profile at least "
130
+ f"{a.profile_length:.0f} m long")
131
+ lines.append(f"Recommended method: {self.recommended}" if self.recommended
132
+ else "No method is expected to detect this target with these assumptions.")
133
+ return "\n".join(lines)
134
+
135
+ def script(self) -> str:
136
+ """A standalone Python script that recomputes this report."""
137
+ target = {k: v for k, v in self.target.items() if v is not None}
138
+ if target.get("shape") != "dyke":
139
+ target.pop("dip", None)
140
+ survey = {k: v for k, v in self.survey.items() if v is not None}
141
+ return SCRIPT.format(
142
+ version=self.version,
143
+ target=json.dumps(target, indent=4, ensure_ascii=False),
144
+ survey=json.dumps(survey, indent=4, ensure_ascii=False),
145
+ methods=json.dumps(self.methods),
146
+ )
147
+
148
+
149
+ SCRIPT = '''"""Reproduces a Detectability result (detectability {version}).
150
+
151
+ Install: pip install "detectability=={version}"
152
+ """
153
+ import detectability
154
+ from detectability import evaluate
155
+
156
+ target = {target}
157
+
158
+ survey = {survey}
159
+
160
+ report = evaluate(target, survey, methods={methods})
161
+ print(report.summary())
162
+ if detectability.__version__ != "{version}":
163
+ print("Note: computed with detectability", detectability.__version__, "- the original used {version}")
164
+
165
+ # Optional plot (needs matplotlib)
166
+ try:
167
+ import matplotlib.pyplot as plt
168
+ except ImportError:
169
+ plt = None
170
+ if plt:
171
+ shown = [r for r in report.results if r.available]
172
+ fig, axes = plt.subplots(len(shown), 1, figsize=(8, 3 * len(shown)), squeeze=False)
173
+ for ax, r in zip(axes[:, 0], shown):
174
+ ax.plot(r.x, r.anomaly, color="black")
175
+ ax.axhspan(-r.assessment.noise, r.assessment.noise, color="grey", alpha=0.2, label="noise")
176
+ ax.set(xlabel="distance along profile (m)", ylabel=f"{{r.method}} ({{r.assessment.units}})")
177
+ ax.legend()
178
+ plt.tight_layout()
179
+ plt.show()
180
+ '''
181
+
182
+
183
+ def _as(cls, value):
184
+ if value is None:
185
+ return cls() if cls is Survey else None
186
+ if isinstance(value, cls):
187
+ return replace(value)
188
+ known = {f.name for f in fields(cls)}
189
+ unknown = set(value) - known
190
+ if unknown:
191
+ raise ValueError(f"unknown {cls.__name__.lower()} field(s): {', '.join(sorted(unknown))}")
192
+ return cls(**value)
193
+
194
+
195
+ def _profile(t: Target) -> np.ndarray:
196
+ size = t.size or 0.0
197
+ if t.shape == "contact":
198
+ ref = t.depth_bottom
199
+ elif t.shape in ("sphere", "horizontal_cylinder"):
200
+ ref = t.depth_top + size / 2
201
+ else:
202
+ ref = (t.depth_top + t.depth_bottom) / 2
203
+ half = max(8.0 * ref, 5.0 * size, 20.0)
204
+ return np.linspace(-half, half, 1201)
205
+
206
+
207
+ def _contrast(t: Target, method: str):
208
+ try:
209
+ if method == "gravity":
210
+ value = t.density_contrast if t.density_contrast is not None else rocks.contrast(t.material, t.host)
211
+ return value, "kg/m^3", ""
212
+ value = t.susceptibility_contrast if t.susceptibility_contrast is not None else \
213
+ rocks.susceptibility_contrast(t.material, t.host)
214
+ return value, "SI", ""
215
+ except KeyError as e:
216
+ return None, "", str(e.args[0]) + " (give the contrast directly)"
217
+
218
+
219
+ def _gravity(t: Target, x, drho):
220
+ r = (t.size or 0) / 2
221
+ if t.shape == "sphere":
222
+ return gravity.sphere(x, t.depth_top + r, r, drho)
223
+ if t.shape == "horizontal_cylinder":
224
+ return gravity.horizontal_cylinder(x, t.depth_top + r, r, drho)
225
+ if t.shape == "vertical_cylinder":
226
+ return gravity.vertical_cylinder(x, t.depth_top, t.depth_bottom, r, drho)
227
+ if t.shape == "prism":
228
+ a, c = t.size / 2, (t.length or t.size) / 2
229
+ return gravity.prism(x, 0.0, -a, a, -c, c, t.depth_top, t.depth_bottom, drho)
230
+ if t.shape == "dyke":
231
+ return gravity.dyke(x, t.depth_top, t.depth_bottom, t.size, drho, dip=t.dip)
232
+ return gravity.vertical_step(x, t.depth_top, t.depth_bottom, drho)
233
+
234
+
235
+ def _magnetic(t: Target, x, chi, amb, s: Survey):
236
+ r = (t.size or 0) / 2
237
+ kw = dict(azimuth=s.profile_azimuth, height=s.sensor_height)
238
+ if t.shape == "sphere":
239
+ return magnetics.sphere(x, t.depth_top + r, r, chi, amb, **kw)
240
+ if t.shape == "horizontal_cylinder":
241
+ return magnetics.horizontal_cylinder(x, t.depth_top + r, r, chi, amb, **kw)
242
+ if t.shape == "vertical_cylinder":
243
+ return magnetics.vertical_cylinder(x, t.depth_top, t.depth_bottom, r, chi, amb, **kw)
244
+ if t.shape == "prism":
245
+ a, c = t.size / 2, (t.length or t.size) / 2
246
+ return magnetics.prism(x, -a, a, -c, c, t.depth_top, t.depth_bottom, chi, amb, **kw)
247
+ if t.shape == "dyke":
248
+ return magnetics.dyke(x, t.depth_top, t.depth_bottom, t.size, chi, amb, dip=t.dip, **kw)
249
+ return magnetics.contact(x, t.depth_top, t.depth_bottom, chi, amb, **kw)
250
+
251
+
252
+ def _main_field(s: Survey):
253
+ given = (s.field_total, s.field_inclination, s.field_declination)
254
+ if all(v is not None for v in given):
255
+ return magnetics.Ambient(*given), {"source": "given", "total": given[0], "inclination": given[1],
256
+ "declination": given[2]}
257
+ if s.latitude is None or s.longitude is None:
258
+ return None, None
259
+ f = igrf.field(s.latitude, s.longitude, _dt.date.fromisoformat(s.date))
260
+ return magnetics.Ambient.from_igrf(f), {"source": f.model, "total": round(f.total, 1),
261
+ "inclination": round(f.inclination, 3), "declination": round(f.declination, 3)}
262
+
263
+
264
+ def evaluate(target, survey=None, methods=METHODS) -> Report:
265
+ """Model ``target`` (a ``Target`` or dict) for each method and assess it for ``survey`` (a ``Survey`` or dict)."""
266
+ t, s = _as(Target, target), _as(Survey, survey)
267
+ t.validate()
268
+ if s.date is None:
269
+ s.date = _dt.date.today().isoformat()
270
+ methods = list(methods)
271
+ unknown = set(methods) - set(METHODS)
272
+ if unknown:
273
+ raise ValueError(f"unknown method(s): {', '.join(sorted(unknown))}; choose from {', '.join(METHODS)}")
274
+
275
+ x = _profile(t)
276
+ results = []
277
+ for method in methods:
278
+ contrast, units, problem = _contrast(t, method)
279
+ if contrast is None:
280
+ results.append(MethodResult(method, False, problem))
281
+ continue
282
+ if method == "gravity":
283
+ inst = s.gravity_instrument_noise if s.gravity_instrument_noise is not None else \
284
+ detect.GRAVIMETERS_UGAL.get(s.gravimeter)
285
+ if inst is None:
286
+ results.append(MethodResult(method, False, f"unknown gravimeter {s.gravimeter!r}"))
287
+ continue
288
+ noise = detect.effective_noise(inst, s.gravity_field_noise)
289
+ anomaly = _gravity(t, x, contrast) * SI_TO_UGAL
290
+ res = MethodResult(method, True, contrast=contrast, contrast_units=units, instrument=s.gravimeter,
291
+ instrument_noise=inst, field_noise=s.gravity_field_noise)
292
+ else:
293
+ amb, info = _main_field(s)
294
+ if amb is None:
295
+ results.append(MethodResult(method, False, "needs the survey location (latitude, longitude) for the "
296
+ "main field, or field_total/inclination/declination"))
297
+ 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)
307
+ a = detect.assess(x, anomaly, noise, depth=t.depth_top, method=method)
308
+ preset = detect.INSTRUMENTS.get(res.instrument)
309
+ overridden = (s.gravity_instrument_noise if method == "gravity" else s.magnetic_instrument_noise) is not None
310
+ if preset and not overridden:
311
+ a.assumptions.append(f"{res.instrument}: {preset.noise:g} {a.units} per reading ({preset.basis}; "
312
+ f"{preset.reference} data sheet).")
313
+ if t.shape in TWO_D:
314
+ a.assumptions.append("2D target: infinitely long along strike, at right angles to the profile.")
315
+ if method == "magnetic":
316
+ a.assumptions.append(f"Sensor {s.sensor_height:g} m above ground; profile azimuth {s.profile_azimuth:g}°; "
317
+ "induced magnetisation only, no demagnetisation.")
318
+ res.x, res.anomaly, res.assessment = x.round(4).tolist(), anomaly.tolist(), a
319
+ results.append(res)
320
+
321
+ ranked = sorted((r for r in results if r.available and r.assessment.verdict != "not detectable"),
322
+ key=lambda r: r.assessment.snr, reverse=True)
323
+ return Report(__version__, asdict(t), asdict(s), methods, results, ranked[0].method if ranked else None)