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/__init__.py +7 -0
- detectability/constants.py +6 -0
- detectability/data/harmonica_reference.json +589 -0
- detectability/data/igrf14coeffs.txt +199 -0
- detectability/detect.py +149 -0
- detectability/gravity.py +266 -0
- detectability/igrf.py +163 -0
- detectability/magnetics.py +199 -0
- detectability/rocks.py +68 -0
- detectability/survey.py +323 -0
- detectability/validation.py +404 -0
- detectability-0.2.1.dist-info/METADATA +166 -0
- detectability-0.2.1.dist-info/RECORD +16 -0
- detectability-0.2.1.dist-info/WHEEL +5 -0
- detectability-0.2.1.dist-info/licenses/LICENSE +28 -0
- detectability-0.2.1.dist-info/top_level.txt +1 -0
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")
|
detectability/survey.py
ADDED
|
@@ -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)
|