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/gravity.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Gravity forward models for simple near-surface targets.
|
|
2
|
+
|
|
3
|
+
Conventions
|
|
4
|
+
-----------
|
|
5
|
+
- Coordinates in metres. Observation points are on the surface (z = 0); depths are positive downwards.
|
|
6
|
+
- ``drho`` is the density contrast in kg/m^3 (target minus host; a void in limestone is about -2600).
|
|
7
|
+
- Results are the vertical attraction gz in m/s^2, positive downwards (positive for excess mass).
|
|
8
|
+
Use ``constants.SI_TO_UGAL`` / ``SI_TO_MGAL`` to convert.
|
|
9
|
+
|
|
10
|
+
References
|
|
11
|
+
----------
|
|
12
|
+
- Telford, W. M., Geldart, L. P., Sheriff, R. E. (1990). Applied Geophysics, 2nd ed., Cambridge University Press, §2.6.
|
|
13
|
+
- Blakely, R. J. (1996). Potential Theory in Gravity and Magnetic Applications, Cambridge University Press, ch. 3 and 9.
|
|
14
|
+
- Nagy, D. (1966). The gravitational attraction of a right rectangular prism. Geophysics 31, 362-371.
|
|
15
|
+
- Plouff, D. (1976). Gravity and magnetic fields of polygonal prisms and application to magnetic terrain corrections.
|
|
16
|
+
Geophysics 41, 727-741.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from .constants import G
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sphere(x, depth: float, radius: float, drho: float, y=0.0):
|
|
29
|
+
"""Buried sphere (cavity, ore body, boulder). ``depth`` is to the centre."""
|
|
30
|
+
if depth <= radius:
|
|
31
|
+
raise ValueError("depth to the centre must exceed the radius (the sphere must be buried)")
|
|
32
|
+
x, y = np.asarray(x, float), np.asarray(y, float)
|
|
33
|
+
mass = 4.0 / 3.0 * np.pi * radius**3 * drho
|
|
34
|
+
r2 = x**2 + y**2 + depth**2
|
|
35
|
+
return G * mass * depth / r2**1.5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def horizontal_cylinder(x, depth: float, radius: float, drho: float):
|
|
39
|
+
"""Infinite horizontal cylinder (tunnel, pipe, gallery) striking perpendicular to the profile.
|
|
40
|
+
``depth`` is to the axis."""
|
|
41
|
+
if depth <= radius:
|
|
42
|
+
raise ValueError("depth to the axis must exceed the radius")
|
|
43
|
+
x = np.asarray(x, float)
|
|
44
|
+
return 2.0 * np.pi * G * drho * radius**2 * depth / (x**2 + depth**2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prism_kernel(dx, dy, dz):
|
|
48
|
+
r = np.sqrt(dx**2 + dy**2 + dz**2)
|
|
49
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
50
|
+
t1 = np.where(dy + r > 0, dx * np.log(dy + r), 0.0)
|
|
51
|
+
t2 = np.where(dx + r > 0, dy * np.log(dx + r), 0.0)
|
|
52
|
+
t3 = np.where(dz * r != 0, dz * np.arctan(dx * dy / (dz * r)), 0.0)
|
|
53
|
+
return t1 + t2 - t3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def prism(x, y, x1: float, x2: float, y1: float, y2: float, z1: float, z2: float, drho: float):
|
|
57
|
+
"""Right rectangular prism (room, foundation, buried block) with edges parallel to the axes.
|
|
58
|
+
|
|
59
|
+
Exact solution (Nagy 1966; Plouff 1976). Horizontal extent [x1, x2] x [y1, y2], top ``z1`` and bottom ``z2`` below
|
|
60
|
+
the surface (0 <= z1 < z2).
|
|
61
|
+
"""
|
|
62
|
+
if not (x1 < x2 and y1 < y2 and 0 <= z1 < z2):
|
|
63
|
+
raise ValueError("need x1 < x2, y1 < y2 and 0 <= z1 < z2")
|
|
64
|
+
x, y = np.broadcast_arrays(np.asarray(x, float), np.asarray(y, float))
|
|
65
|
+
total = np.zeros(x.shape)
|
|
66
|
+
for i, xi in enumerate((x1, x2)):
|
|
67
|
+
for j, yj in enumerate((y1, y2)):
|
|
68
|
+
for k, zk in enumerate((z1, z2)):
|
|
69
|
+
sign = (-1) ** (i + j + k)
|
|
70
|
+
total += sign * _prism_kernel(xi - x, yj - y, zk)
|
|
71
|
+
return G * drho * total
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _atan_ratio(num, den):
|
|
75
|
+
"""arctan(num / den), taken as 0 where den = 0 (those terms cancel in the vertex sum for points outside the body)."""
|
|
76
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
77
|
+
return np.where(den != 0, np.arctan(num / np.where(den != 0, den, 1.0)), 0.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def prism_tensor(x, y, x1: float, x2: float, y1: float, y2: float, z1: float, z2: float):
|
|
81
|
+
"""Gravity gradient tensor of a right rectangular prism per unit G * drho, as an array (..., 3, 3) in (x, y, z down).
|
|
82
|
+
|
|
83
|
+
T_ij = d g_i / d x_j for g = grad U, U = G drho * integral dV / |r - r'| (Nagy, Papp & Benedek 2000, J. Geodesy
|
|
84
|
+
74, 552-560). Observation points on the surface z = 0 above the prism (0 <= z1 < z2; if z1 = 0 keep the points off
|
|
85
|
+
the top face). Multiply by G * drho for s^-2; used by ``magnetics.prism`` through Poisson's relation.
|
|
86
|
+
"""
|
|
87
|
+
if not (x1 < x2 and y1 < y2 and 0 <= z1 < z2):
|
|
88
|
+
raise ValueError("need x1 < x2, y1 < y2 and 0 <= z1 < z2")
|
|
89
|
+
x, y = np.broadcast_arrays(np.asarray(x, float), np.asarray(y, float))
|
|
90
|
+
t = np.zeros(x.shape + (3, 3))
|
|
91
|
+
for i, xi in enumerate((x1, x2)):
|
|
92
|
+
for j, yj in enumerate((y1, y2)):
|
|
93
|
+
for k, zk in enumerate((z1, z2)):
|
|
94
|
+
s = (-1) ** (i + j + k)
|
|
95
|
+
dx, dy, dz = xi - x, yj - y, np.full(x.shape, zk)
|
|
96
|
+
r = np.sqrt(dx**2 + dy**2 + dz**2)
|
|
97
|
+
t[..., 0, 0] += s * _atan_ratio(dy * dz, dx * r)
|
|
98
|
+
t[..., 1, 1] += s * _atan_ratio(dx * dz, dy * r)
|
|
99
|
+
t[..., 2, 2] += s * _atan_ratio(dx * dy, dz * r)
|
|
100
|
+
t[..., 0, 1] -= s * np.log(dz + r)
|
|
101
|
+
t[..., 0, 2] -= s * np.log(dy + r)
|
|
102
|
+
t[..., 1, 2] -= s * np.log(dx + r)
|
|
103
|
+
t[..., 1, 0], t[..., 2, 0], t[..., 2, 1] = t[..., 0, 1], t[..., 0, 2], t[..., 1, 2]
|
|
104
|
+
return t
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- vertical cylinder ----------------------------------------------------------------------------------------
|
|
108
|
+
# The volume integral is done in closed form along the depth and along every ray from the station; what is left is one
|
|
109
|
+
# integral round the rim (Gauss's theorem in the horizontal plane). Each term is written as its difference from the
|
|
110
|
+
# value at zero distance, divided by the matching power of the distance, so the integrand is an analytic periodic
|
|
111
|
+
# function of the rim angle, also for stations right above the rim. The trapezoid rule then converges geometrically:
|
|
112
|
+
# the nearest complex singularity is at Im(theta) = arccosh(1 + ((R - d)^2 + z1^2) / (2 R d)), which sets the number of
|
|
113
|
+
# nodes. Small arguments use Taylor series to avoid cancellation.
|
|
114
|
+
|
|
115
|
+
_C = np.array([math.comb(2 * n, n) / 4.0**n for n in range(16)]) # coefficients of asinh and 1/sqrt(1 + x^2)
|
|
116
|
+
_S_SERIES = np.array([(-1) ** n * _C[n] / (2 * n + 1) for n in range(1, 13)]) # (z asinh(x) - z x) / (z x)^3 * z^2
|
|
117
|
+
_C_SERIES = np.array([(-1) ** (n + 1) * _C[n] * 2 * n / (2 * n + 1) for n in range(1, 13)]) # (asinh x - x / sqrt(1+x^2)) / x^3
|
|
118
|
+
_E_SERIES = np.array([2 * (1 - 2.0**-n) / n for n in range(2, 18)]) # (2 ln((1+t)/(2t)) - 1 + t) / eps^2, t = 1 - eps
|
|
119
|
+
_L_SERIES = np.array([(1 - 2.0**-n) / n for n in range(1, 17)]) # ln((1+t)/(2t)) / eps
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _rim_terms(rho2, z: float) -> dict:
|
|
123
|
+
"""Per-depth terms of the rim integrand at squared distance ``rho2`` from the station, for a face at depth ``z``."""
|
|
124
|
+
u = np.sqrt(rho2 + z * z)
|
|
125
|
+
k = 1.0 / (u * (u + z)) # (1 - z/u) / rho^2
|
|
126
|
+
eps = rho2 * k # 1 - z/u, computed without cancellation
|
|
127
|
+
x2 = rho2 / (z * z)
|
|
128
|
+
small, small_eps = x2 < 0.0625, eps < 0.1
|
|
129
|
+
rho = np.sqrt(rho2)
|
|
130
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
131
|
+
c_direct = (np.arcsinh(rho / z) - rho / u) / (rho2 * rho)
|
|
132
|
+
ln_ratio = np.log1p(-eps / 2) - np.log1p(-eps) # ln((z + u) / (2 z))
|
|
133
|
+
e_direct = (2 * ln_ratio - eps) / eps**2
|
|
134
|
+
l_direct = ln_ratio / eps
|
|
135
|
+
return {
|
|
136
|
+
"gz": 1.0 / (u + z), # (u - z) / rho^2
|
|
137
|
+
"tzz": k,
|
|
138
|
+
"tzh": np.where(small, np.polynomial.polynomial.polyval(x2, _C_SERIES) / z**3, c_direct),
|
|
139
|
+
"ww": np.where(small_eps, np.polynomial.polynomial.polyval(eps, _E_SERIES), e_direct) * k * k,
|
|
140
|
+
"ii": np.where(small_eps, np.polynomial.polynomial.polyval(eps, _L_SERIES), l_direct) * k,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _rim_nodes(d, radius: float, z1: float) -> np.ndarray:
|
|
145
|
+
"""Trapezoid nodes per station for about 15 correct digits (a power of two, 16 to 65,536)."""
|
|
146
|
+
with np.errstate(divide="ignore"):
|
|
147
|
+
excess = ((radius - d) ** 2 + z1 * z1) / (2.0 * radius * d) # inf on the axis
|
|
148
|
+
a = 2.0 * np.arcsinh(np.sqrt(excess / 2.0)) # arccosh(1 + excess), also when tiny
|
|
149
|
+
n = np.clip(36.0 / a, 16, 65536)
|
|
150
|
+
return (2 ** np.ceil(np.log2(n))).astype(int)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cylinder_rim(x, y, radius: float, z1: float, z2: float, tensor: bool):
|
|
154
|
+
"""gz (and the gradient tensor) of a vertical cylinder per unit G * drho, by the rim integral."""
|
|
155
|
+
if not (radius > 0 and 0 <= z1 < z2):
|
|
156
|
+
raise ValueError("need radius > 0 and 0 <= top < bottom")
|
|
157
|
+
z1 = max(z1, 1e-9 * max(radius, z2)) # a top exactly at the surface: the limit, within 1e-9 of the body size
|
|
158
|
+
x, y = np.broadcast_arrays(np.asarray(x, float), np.asarray(y, float))
|
|
159
|
+
ox, oy = x.ravel(), y.ravel()
|
|
160
|
+
gz = np.zeros(ox.shape)
|
|
161
|
+
t = np.zeros(ox.shape + (3, 3))
|
|
162
|
+
nodes = _rim_nodes(np.hypot(ox, oy), radius, z1)
|
|
163
|
+
for n in np.unique(nodes):
|
|
164
|
+
theta = (np.arange(n) + 0.5) * (2.0 * np.pi / n) # half-step grid: never exactly at a station on the x axis
|
|
165
|
+
cos, sin = np.cos(theta), np.sin(theta)
|
|
166
|
+
members = np.flatnonzero(nodes == n)
|
|
167
|
+
for chunk in np.array_split(members, max(1, len(members) * n // 400_000)):
|
|
168
|
+
px, py = ox[chunk, None], oy[chunk, None]
|
|
169
|
+
wx, wy = radius * cos - px, radius * sin - py # station to rim
|
|
170
|
+
rho2 = wx * wx + wy * wy
|
|
171
|
+
dphi = (radius * radius - radius * (px * cos + py * sin)) * (2.0 * np.pi / n) # rho^2 dphi / dtheta * h
|
|
172
|
+
top, base = _rim_terms(rho2, z1), _rim_terms(rho2, z2)
|
|
173
|
+
gz[chunk] = np.sum((top["gz"] - base["gz"]) * dphi, axis=1)
|
|
174
|
+
if tensor:
|
|
175
|
+
zh = (top["tzh"] - base["tzh"]) * dphi
|
|
176
|
+
ww = (top["ww"] - base["ww"]) * dphi
|
|
177
|
+
ii = (top["ii"] - base["ii"]) * dphi
|
|
178
|
+
tc = t[chunk]
|
|
179
|
+
tc[:, 2, 2] = np.sum((top["tzz"] - base["tzz"]) * dphi, axis=1)
|
|
180
|
+
tc[:, 0, 2] = tc[:, 2, 0] = np.sum(zh * wx, axis=1)
|
|
181
|
+
tc[:, 1, 2] = tc[:, 2, 1] = np.sum(zh * wy, axis=1)
|
|
182
|
+
tc[:, 0, 0] = np.sum(ww * wx * wx - ii, axis=1)
|
|
183
|
+
tc[:, 1, 1] = np.sum(ww * wy * wy - ii, axis=1)
|
|
184
|
+
tc[:, 0, 1] = tc[:, 1, 0] = np.sum(ww * wx * wy, axis=1)
|
|
185
|
+
t[chunk] = tc
|
|
186
|
+
return gz.reshape(x.shape), t.reshape(x.shape + (3, 3))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def vertical_cylinder(x, top: float, bottom: float, radius: float, drho: float, y=0.0):
|
|
190
|
+
"""Vertical cylinder (shaft, sinkhole pipe, well) from ``top`` to ``bottom`` depth, axis at x = y = 0.
|
|
191
|
+
|
|
192
|
+
Exact: the volume integral is reduced to one integral round the rim (closed form in depth and distance) and summed
|
|
193
|
+
with the trapezoid rule to about 15 digits. A top at the surface (0) is allowed; right above the rim the sum then
|
|
194
|
+
converges slowly and the value there is good to about 1e-6.
|
|
195
|
+
"""
|
|
196
|
+
gz, _ = _cylinder_rim(x, y, radius, top, bottom, tensor=False)
|
|
197
|
+
return G * drho * gz
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def vertical_cylinder_tensor(x, y, radius: float, z1: float, z2: float):
|
|
201
|
+
"""Gravity gradient tensor of a vertical cylinder per unit G * drho, as an array (..., 3, 3) in (x, y, z down), with
|
|
202
|
+
the same convention as ``prism_tensor``; used by ``magnetics.vertical_cylinder`` through Poisson's relation."""
|
|
203
|
+
_, t = _cylinder_rim(x, y, radius, z1, z2, tensor=True)
|
|
204
|
+
return t
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def vertical_step(x, top: float, bottom: float, drho: float, edge: float = 0.0):
|
|
208
|
+
"""2D faulted slab / vertical step: a horizontal slab from ``top`` to ``bottom`` depth that exists for x > ``edge``
|
|
209
|
+
and ends at a vertical contact at x = ``edge`` (fault, buried wall, edge of a fill).
|
|
210
|
+
|
|
211
|
+
Far over the slab gz tends to the Bouguer value 2*pi*G*drho*t; far on the other side it tends to 0 (Telford et al.,
|
|
212
|
+
§2.6).
|
|
213
|
+
"""
|
|
214
|
+
if not 0 <= top < bottom:
|
|
215
|
+
raise ValueError("need 0 <= top < bottom")
|
|
216
|
+
u = np.asarray(x, float) - edge
|
|
217
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
218
|
+
log_term = np.where(u != 0, 0.5 * u * np.log((u**2 + bottom**2) / (u**2 + top**2)), 0.0)
|
|
219
|
+
at_bottom = bottom * np.arctan2(u, bottom)
|
|
220
|
+
at_top = np.where(top > 0, top * np.arctan2(u, top), 0.0)
|
|
221
|
+
return 2.0 * G * drho * (log_term + np.pi / 2.0 * (bottom - top) + at_bottom - at_top)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def dyke(x, top: float, bottom: float, width: float, drho: float, centre: float = 0.0, dip: float = 90.0):
|
|
225
|
+
"""2D dyke (or buried wall, filled trench, vein) of horizontal ``width`` centred at ``centre``. A vertical dyke is
|
|
226
|
+
the difference of two vertical steps; for ``dip`` below 90 degrees (towards +x) the parallelogram section is
|
|
227
|
+
computed with ``polygon``."""
|
|
228
|
+
if width <= 0:
|
|
229
|
+
raise ValueError("width must be positive")
|
|
230
|
+
if dip != 90.0:
|
|
231
|
+
if not 0 < dip < 180:
|
|
232
|
+
raise ValueError("dip must be within (0, 180) degrees")
|
|
233
|
+
shift = (bottom - top) / np.tan(np.radians(dip))
|
|
234
|
+
w = width / 2.0
|
|
235
|
+
verts = [(centre - w, top), (centre + w, top), (centre + w + shift, bottom), (centre - w + shift, bottom)]
|
|
236
|
+
return polygon(x, verts, drho)
|
|
237
|
+
return (vertical_step(x, top, bottom, drho, centre - width / 2.0)
|
|
238
|
+
- vertical_step(x, top, bottom, drho, centre + width / 2.0))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def polygon(x, vertices, drho: float):
|
|
242
|
+
"""2D body of polygonal cross-section striking perpendicular to the profile (Talwani, Worzel & Landisman 1959;
|
|
243
|
+
closed form of Won & Bevis 1987, Geophysics 52, 232-238). ``vertices`` are (distance along profile, depth) pairs in
|
|
244
|
+
either order; the body must lie below the surface."""
|
|
245
|
+
v = np.asarray(vertices, float)
|
|
246
|
+
if v.ndim != 2 or v.shape[1] != 2 or len(v) < 3:
|
|
247
|
+
raise ValueError("need at least three (x, depth) vertices")
|
|
248
|
+
if v[:, 1].min() < 0:
|
|
249
|
+
raise ValueError("the body must lie below the surface")
|
|
250
|
+
if np.sum(v[:, 0] * np.roll(v[:, 1], -1) - np.roll(v[:, 0], -1) * v[:, 1]) < 0:
|
|
251
|
+
v = v[::-1] # counter-clockwise in the (x, depth) plane
|
|
252
|
+
x = np.asarray(x, float)
|
|
253
|
+
total = np.zeros(x.shape)
|
|
254
|
+
for (xa, za), (xb, zb) in zip(v, np.roll(v, -1, axis=0)):
|
|
255
|
+
x1, x2 = xa - x, xb - x
|
|
256
|
+
dx, dz = xb - xa, zb - za
|
|
257
|
+
len2 = dx**2 + dz**2
|
|
258
|
+
if len2 == 0:
|
|
259
|
+
continue
|
|
260
|
+
r1, r2 = np.hypot(x1, za), np.hypot(x2, zb)
|
|
261
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
262
|
+
theta = np.arctan2(za * x2 - zb * x1, x1 * x2 + za * zb) # angle from vertex a to vertex b
|
|
263
|
+
log_term = np.where((r1 > 0) & (r2 > 0), np.log(np.where(r1 > 0, r2, 1.0) / np.where(r1 > 0, r1, 1.0)), 0.0)
|
|
264
|
+
cross = x1 * zb - x2 * za
|
|
265
|
+
total += cross / len2 * (dx * theta + dz * log_term)
|
|
266
|
+
return 2.0 * G * drho * total
|
detectability/igrf.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Main geomagnetic field from the International Geomagnetic Reference Field, 14th generation (IGRF-14).
|
|
2
|
+
|
|
3
|
+
The coefficients in ``data/igrf14coeffs.txt`` are the official IAGA release (NOAA NCEI mirror,
|
|
4
|
+
https://www.ngdc.noaa.gov/IAGA/vmod/coeffs/igrf14coeffs.txt), unchanged. Between epochs the coefficients are interpolated
|
|
5
|
+
linearly; after 2025.0 the secular variation is applied, up to 2030.0 (the model's validity limit).
|
|
6
|
+
|
|
7
|
+
The synthesis follows the standard IGRF/WMM algorithm: geodetic (WGS84) to geocentric conversion, Schmidt
|
|
8
|
+
semi-normalised associated Legendre functions, and rotation of the field components back to the geodetic frame.
|
|
9
|
+
|
|
10
|
+
References
|
|
11
|
+
----------
|
|
12
|
+
- Alken, P. et al. (2021). International Geomagnetic Reference Field: the thirteenth generation. Earth, Planets and Space
|
|
13
|
+
73, 49. (Method; IGRF-14 uses the same formulation.)
|
|
14
|
+
- Chulliat, A. et al. (2020). The US/UK World Magnetic Model for 2020-2025, Technical Report, NOAA NCEI (algorithm and
|
|
15
|
+
Legendre recursion).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import datetime as _dt
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from importlib import resources
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
A_REF_KM = 6371.2 # IGRF reference radius
|
|
28
|
+
WGS84_A_KM = 6378.137
|
|
29
|
+
WGS84_B_KM = WGS84_A_KM * (1.0 - 1.0 / 298.257223563)
|
|
30
|
+
VALID_FROM, VALID_TO = 1900.0, 2030.0
|
|
31
|
+
MODEL = "IGRF-14"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MainField:
|
|
36
|
+
"""Main field at a point. Components in nT: north (X), east (Y), down (Z); angles in degrees."""
|
|
37
|
+
|
|
38
|
+
north: float
|
|
39
|
+
east: float
|
|
40
|
+
down: float
|
|
41
|
+
total: float
|
|
42
|
+
horizontal: float
|
|
43
|
+
inclination: float # positive downwards (northern hemisphere)
|
|
44
|
+
declination: float # positive east of geographic north
|
|
45
|
+
model: str = MODEL
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def _coefficients():
|
|
50
|
+
text = resources.files("detectability").joinpath("data/igrf14coeffs.txt").read_text()
|
|
51
|
+
epochs: list[float] | None = None
|
|
52
|
+
rows: list[tuple[str, int, int, list[float]]] = []
|
|
53
|
+
for line in text.splitlines():
|
|
54
|
+
parts = line.split()
|
|
55
|
+
if not parts or line.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
if parts[0] == "g/h":
|
|
58
|
+
epochs = [float(p) for p in parts[3:-1]] # the last column is the secular variation, e.g. "2025-30"
|
|
59
|
+
continue
|
|
60
|
+
if parts[0] in ("g", "h"):
|
|
61
|
+
rows.append((parts[0], int(parts[1]), int(parts[2]), [float(p) for p in parts[3:]]))
|
|
62
|
+
if epochs is None or not rows:
|
|
63
|
+
raise RuntimeError("IGRF coefficient file not recognised")
|
|
64
|
+
nmax = max(n for _, n, _, _ in rows)
|
|
65
|
+
g = np.zeros((len(epochs), nmax + 1, nmax + 1))
|
|
66
|
+
h = np.zeros_like(g)
|
|
67
|
+
g_sv = np.zeros((nmax + 1, nmax + 1))
|
|
68
|
+
h_sv = np.zeros_like(g_sv)
|
|
69
|
+
for kind, n, m, values in rows:
|
|
70
|
+
main, sv = values[:-1], values[-1]
|
|
71
|
+
(g if kind == "g" else h)[:, n, m] = main
|
|
72
|
+
(g_sv if kind == "g" else h_sv)[n, m] = sv
|
|
73
|
+
return np.array(epochs), g, h, g_sv, h_sv, nmax
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decimal_year(date: _dt.date | _dt.datetime | float) -> float:
|
|
77
|
+
if isinstance(date, (int, float)):
|
|
78
|
+
return float(date)
|
|
79
|
+
start = _dt.date(date.year, 1, 1)
|
|
80
|
+
days = (_dt.date(date.year + 1, 1, 1) - start).days
|
|
81
|
+
day = (date.date() if isinstance(date, _dt.datetime) else date) - start
|
|
82
|
+
return date.year + day.days / days
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _coefficients_at(year: float):
|
|
86
|
+
if not VALID_FROM <= year <= VALID_TO:
|
|
87
|
+
raise ValueError(f"{MODEL} is defined from {VALID_FROM:g} to {VALID_TO:g}; got {year:.3f}")
|
|
88
|
+
epochs, g, h, g_sv, h_sv, nmax = _coefficients()
|
|
89
|
+
if year >= epochs[-1]:
|
|
90
|
+
dt = year - epochs[-1]
|
|
91
|
+
return g[-1] + g_sv * dt, h[-1] + h_sv * dt, nmax
|
|
92
|
+
i = int(np.searchsorted(epochs, year, side="right") - 1)
|
|
93
|
+
w = (year - epochs[i]) / (epochs[i + 1] - epochs[i])
|
|
94
|
+
return (1 - w) * g[i] + w * g[i + 1], (1 - w) * h[i] + w * h[i + 1], nmax
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _schmidt_legendre(nmax: int, cos_t: float, sin_t: float):
|
|
98
|
+
"""Schmidt semi-normalised P[n, m](cos theta) and dP/dtheta (Gauss-normalised recursion, then scaling)."""
|
|
99
|
+
P = np.zeros((nmax + 1, nmax + 1))
|
|
100
|
+
dP = np.zeros_like(P)
|
|
101
|
+
P[0, 0] = 1.0
|
|
102
|
+
for n in range(1, nmax + 1):
|
|
103
|
+
for m in range(n + 1):
|
|
104
|
+
if n == m:
|
|
105
|
+
P[n, n] = sin_t * P[n - 1, n - 1]
|
|
106
|
+
dP[n, n] = sin_t * dP[n - 1, n - 1] + cos_t * P[n - 1, n - 1]
|
|
107
|
+
else:
|
|
108
|
+
k = ((n - 1) ** 2 - m**2) / ((2 * n - 1) * (2 * n - 3)) if n > 1 else 0.0
|
|
109
|
+
P[n, m] = cos_t * P[n - 1, m] - (k * P[n - 2, m] if n > 1 else 0.0)
|
|
110
|
+
dP[n, m] = cos_t * dP[n - 1, m] - sin_t * P[n - 1, m] - (k * dP[n - 2, m] if n > 1 else 0.0)
|
|
111
|
+
S = np.zeros_like(P)
|
|
112
|
+
S[0, 0] = 1.0
|
|
113
|
+
for n in range(1, nmax + 1):
|
|
114
|
+
S[n, 0] = S[n - 1, 0] * (2 * n - 1) / n
|
|
115
|
+
for m in range(1, n + 1):
|
|
116
|
+
S[n, m] = S[n, m - 1] * np.sqrt((n - m + 1) * (2.0 if m == 1 else 1.0) / (n + m))
|
|
117
|
+
return P * S, dP * S
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def field(latitude: float, longitude: float, date: _dt.date | _dt.datetime | float, altitude_km: float = 0.0) -> MainField:
|
|
121
|
+
"""IGRF-14 main field at geodetic ``latitude``/``longitude`` (degrees), ``altitude_km`` above the WGS84 ellipsoid,
|
|
122
|
+
on ``date`` (a date or a decimal year)."""
|
|
123
|
+
if not -90.0 <= latitude <= 90.0:
|
|
124
|
+
raise ValueError("latitude must be within [-90, 90]")
|
|
125
|
+
g, h, nmax = _coefficients_at(decimal_year(date))
|
|
126
|
+
|
|
127
|
+
# geodetic -> geocentric (radius r and colatitude theta)
|
|
128
|
+
lat = np.radians(latitude)
|
|
129
|
+
ct, st = np.sin(lat), np.cos(lat) # cos and sin of the geodetic colatitude
|
|
130
|
+
a2, b2 = WGS84_A_KM**2, WGS84_B_KM**2
|
|
131
|
+
one, two = a2 * st * st, b2 * ct * ct
|
|
132
|
+
rho = np.sqrt(one + two)
|
|
133
|
+
r = np.sqrt(altitude_km * (altitude_km + 2.0 * rho) + (a2 * one + b2 * two) / (one + two))
|
|
134
|
+
cd = (altitude_km + rho) / r
|
|
135
|
+
sd = (a2 - b2) / rho * ct * st / r
|
|
136
|
+
cos_t, sin_t = ct * cd - st * sd, st * cd + ct * sd
|
|
137
|
+
sin_t = max(sin_t, 1e-10) # avoid division by zero exactly at the poles
|
|
138
|
+
|
|
139
|
+
P, dP = _schmidt_legendre(nmax, cos_t, sin_t)
|
|
140
|
+
phi = np.radians(longitude)
|
|
141
|
+
x = y = z = 0.0
|
|
142
|
+
for n in range(1, nmax + 1):
|
|
143
|
+
ratio = (A_REF_KM / r) ** (n + 2)
|
|
144
|
+
for m in range(n + 1):
|
|
145
|
+
cm, sm = np.cos(m * phi), np.sin(m * phi)
|
|
146
|
+
gh = g[n, m] * cm + h[n, m] * sm
|
|
147
|
+
x += ratio * gh * dP[n, m]
|
|
148
|
+
y += ratio * m * (g[n, m] * sm - h[n, m] * cm) * P[n, m] / sin_t
|
|
149
|
+
z -= ratio * (n + 1) * gh * P[n, m]
|
|
150
|
+
|
|
151
|
+
# rotate back from geocentric to geodetic north/down
|
|
152
|
+
north, down = x * cd + z * sd, z * cd - x * sd
|
|
153
|
+
horizontal = float(np.hypot(north, y))
|
|
154
|
+
total = float(np.sqrt(horizontal**2 + down**2))
|
|
155
|
+
return MainField(
|
|
156
|
+
north=float(north),
|
|
157
|
+
east=float(y),
|
|
158
|
+
down=float(down),
|
|
159
|
+
total=total,
|
|
160
|
+
horizontal=horizontal,
|
|
161
|
+
inclination=float(np.degrees(np.arctan2(down, horizontal))),
|
|
162
|
+
declination=float(np.degrees(np.arctan2(y, north))),
|
|
163
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Total-field magnetic anomaly of simple near-surface targets.
|
|
2
|
+
|
|
3
|
+
Conventions
|
|
4
|
+
-----------
|
|
5
|
+
- Right-handed frame: x north, y east, z down; depths positive downwards; metres.
|
|
6
|
+
- The ambient (main) field is given by its total intensity (nT), inclination (degrees, positive down) and declination
|
|
7
|
+
(degrees east of north); ``igrf.field`` provides it from location and date.
|
|
8
|
+
- ``susceptibility`` is the contrast (target minus host) in SI units. Induced magnetisation is M = chi * F / mu0;
|
|
9
|
+
self-demagnetisation is neglected (valid for chi << 1). Optional remanent magnetisation is given as
|
|
10
|
+
``(intensity A/m, inclination, declination)``.
|
|
11
|
+
- Profiles run along ``azimuth`` (degrees east of north) through the target; ``x`` is the distance along the profile.
|
|
12
|
+
The sensor is ``height`` metres above the ground.
|
|
13
|
+
- Results are the total-field anomaly in nT: the projection of the anomalous field on the main-field direction, valid when
|
|
14
|
+
the anomaly is much smaller than the main field.
|
|
15
|
+
- 2D targets (horizontal cylinder, dyke, contact) strike perpendicular to the profile and extend to infinity along strike.
|
|
16
|
+
|
|
17
|
+
References
|
|
18
|
+
----------
|
|
19
|
+
- Blakely, R. J. (1996). Potential Theory in Gravity and Magnetic Applications, Cambridge University Press, ch. 4, 5, 9.
|
|
20
|
+
- Telford, W. M., Geldart, L. P., Sheriff, R. E. (1990). Applied Geophysics, 2nd ed., Cambridge University Press, ch. 3.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
MU0 = 4e-7 * np.pi
|
|
30
|
+
T_TO_NT = 1e9
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Ambient:
|
|
35
|
+
"""Main field: total intensity (nT), inclination and declination (degrees)."""
|
|
36
|
+
|
|
37
|
+
total: float
|
|
38
|
+
inclination: float
|
|
39
|
+
declination: float
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_igrf(cls, main_field) -> "Ambient":
|
|
43
|
+
return cls(main_field.total, main_field.inclination, main_field.declination)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def unit_vector(inclination: float, declination: float) -> np.ndarray:
|
|
47
|
+
"""Unit vector (north, east, down) for a direction given by inclination and declination in degrees."""
|
|
48
|
+
i, d = np.radians(inclination), np.radians(declination)
|
|
49
|
+
return np.array([np.cos(i) * np.cos(d), np.cos(i) * np.sin(d), np.sin(i)])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def magnetisation(susceptibility: float, ambient: Ambient, remanence: tuple[float, float, float] | None = None) -> np.ndarray:
|
|
53
|
+
"""Magnetisation vector (north, east, down) in A/m: induced plus optional remanent."""
|
|
54
|
+
induced = susceptibility * ambient.total / T_TO_NT / MU0 * unit_vector(ambient.inclination, ambient.declination)
|
|
55
|
+
if remanence is None:
|
|
56
|
+
return induced
|
|
57
|
+
intensity, inc, dec = remanence
|
|
58
|
+
return induced + intensity * unit_vector(inc, dec)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _profile_points(x, azimuth: float, height: float):
|
|
62
|
+
x = np.asarray(x, float)
|
|
63
|
+
a = np.radians(azimuth)
|
|
64
|
+
return np.stack([x * np.cos(a), x * np.sin(a), np.full_like(x, -height)], axis=-1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sphere(x, depth: float, radius: float, susceptibility: float, ambient: Ambient, azimuth: float = 0.0,
|
|
68
|
+
height: float = 0.0, remanence=None):
|
|
69
|
+
"""Buried sphere (dipole): cavity, ore body, buried object. ``depth`` is to the centre."""
|
|
70
|
+
if depth <= radius:
|
|
71
|
+
raise ValueError("depth to the centre must exceed the radius (the sphere must be buried)")
|
|
72
|
+
m = magnetisation(susceptibility, ambient, remanence) * (4.0 / 3.0 * np.pi * radius**3)
|
|
73
|
+
r = _profile_points(x, azimuth, height) - np.array([0.0, 0.0, depth])
|
|
74
|
+
dist = np.linalg.norm(r, axis=-1, keepdims=True)
|
|
75
|
+
rhat = r / dist
|
|
76
|
+
b = MU0 / (4 * np.pi) * (3.0 * (rhat @ m)[..., None] * rhat - m) / dist**3
|
|
77
|
+
return b @ unit_vector(ambient.inclination, ambient.declination) * T_TO_NT
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _in_plane(vec: np.ndarray, azimuth: float) -> np.ndarray:
|
|
81
|
+
"""Components of a (north, east, down) vector along the profile and downwards."""
|
|
82
|
+
a = np.radians(azimuth)
|
|
83
|
+
return np.array([vec[0] * np.cos(a) + vec[1] * np.sin(a), vec[2]])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def horizontal_cylinder(x, depth: float, radius: float, susceptibility: float, ambient: Ambient, azimuth: float = 0.0,
|
|
87
|
+
height: float = 0.0, remanence=None):
|
|
88
|
+
"""Infinite horizontal cylinder (tunnel, pipe, gallery) striking perpendicular to the profile; ``depth`` is to the
|
|
89
|
+
axis. A 2D line dipole: B = mu0 / (2 pi r^2) * (2 (m.r) r - m)."""
|
|
90
|
+
if depth <= radius:
|
|
91
|
+
raise ValueError("depth to the axis must exceed the radius")
|
|
92
|
+
m = _in_plane(magnetisation(susceptibility, ambient, remanence), azimuth) * (np.pi * radius**2)
|
|
93
|
+
u = np.asarray(x, float)
|
|
94
|
+
r = np.stack([u, np.full_like(u, -height - depth)], axis=-1)
|
|
95
|
+
dist2 = np.sum(r**2, axis=-1, keepdims=True)
|
|
96
|
+
rhat = r / np.sqrt(dist2)
|
|
97
|
+
b = MU0 / (2 * np.pi) * (2.0 * (rhat @ m)[..., None] * rhat - m) / dist2
|
|
98
|
+
return b @ _in_plane(unit_vector(ambient.inclination, ambient.declination), azimuth) * T_TO_NT
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def polygon(x, vertices, susceptibility: float, ambient: Ambient, azimuth: float = 0.0, height: float = 0.0,
|
|
102
|
+
remanence=None):
|
|
103
|
+
"""2D body of polygonal cross-section (any shape), striking perpendicular to the profile.
|
|
104
|
+
|
|
105
|
+
``vertices`` are (distance along profile, depth) pairs in either order. The uniformly magnetised body is replaced by
|
|
106
|
+
the magnetic surface charge M.n on its sides; each straight side has a closed-form field (Blakely 1996, ch. 9;
|
|
107
|
+
equivalent to Talwani & Heirtzler 1964).
|
|
108
|
+
"""
|
|
109
|
+
v = np.asarray(vertices, float)
|
|
110
|
+
if v.ndim != 2 or v.shape[1] != 2 or len(v) < 3:
|
|
111
|
+
raise ValueError("need at least three (x, depth) vertices")
|
|
112
|
+
if v[:, 1].min() + height <= 0:
|
|
113
|
+
raise ValueError("the body must lie below the sensor (raise the sensor or bury the body)")
|
|
114
|
+
area2 = np.sum(v[:, 0] * np.roll(v[:, 1], -1) - np.roll(v[:, 0], -1) * v[:, 1])
|
|
115
|
+
if area2 < 0: # make the order counter-clockwise in the (x, depth) plane
|
|
116
|
+
v = v[::-1]
|
|
117
|
+
m = _in_plane(magnetisation(susceptibility, ambient, remanence), azimuth)
|
|
118
|
+
f = _in_plane(unit_vector(ambient.inclination, ambient.declination), azimuth)
|
|
119
|
+
obs = np.stack([np.asarray(x, float), np.full(np.shape(x), -height, float)], axis=-1)
|
|
120
|
+
b = np.zeros(obs.shape)
|
|
121
|
+
for p1, p2 in zip(v, np.roll(v, -1, axis=0)):
|
|
122
|
+
side = p2 - p1
|
|
123
|
+
length = np.hypot(*side)
|
|
124
|
+
if length == 0:
|
|
125
|
+
continue
|
|
126
|
+
t = side / length
|
|
127
|
+
n = np.array([t[1], -t[0]]) # outward normal for a counter-clockwise polygon
|
|
128
|
+
sigma = m @ n # magnetic surface charge, A/m
|
|
129
|
+
rel = obs - p1
|
|
130
|
+
a, c = rel @ t, rel @ n # along-side and normal coordinates of the observation point
|
|
131
|
+
r1 = np.hypot(a, c)
|
|
132
|
+
r2 = np.hypot(a - length, c)
|
|
133
|
+
along = np.log(r1 / r2)
|
|
134
|
+
normal = np.arctan2(c * length, c**2 + a * (a - length))
|
|
135
|
+
b += sigma * (along[..., None] * t + normal[..., None] * n)
|
|
136
|
+
return MU0 / (2 * np.pi) * (b @ f) * T_TO_NT
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def dyke(x, top: float, bottom: float, width: float, susceptibility: float, ambient: Ambient, azimuth: float = 0.0,
|
|
140
|
+
dip: float = 90.0, centre: float = 0.0, height: float = 0.0, remanence=None):
|
|
141
|
+
"""2D dyke (or buried wall, filled trench, vein) of horizontal ``width``, from ``top`` to ``bottom`` depth.
|
|
142
|
+
``dip`` in degrees from horizontal, towards +x for dips below 90."""
|
|
143
|
+
if not (0 <= top < bottom and width > 0 and 0 < dip <= 180):
|
|
144
|
+
raise ValueError("need 0 <= top < bottom, width > 0 and 0 < dip <= 180")
|
|
145
|
+
shift = (bottom - top) / np.tan(np.radians(dip)) if dip != 90 else 0.0
|
|
146
|
+
w = width / 2.0
|
|
147
|
+
verts = [(centre - w, top), (centre + w, top), (centre + w + shift, bottom), (centre - w + shift, bottom)]
|
|
148
|
+
return polygon(x, verts, susceptibility, ambient, azimuth, height, remanence)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _to_profile_frame(vec: np.ndarray, azimuth: float) -> np.ndarray:
|
|
152
|
+
"""(north, east, down) -> (along profile, across profile, down); right-handed like the input frame."""
|
|
153
|
+
a = np.radians(azimuth)
|
|
154
|
+
return np.array([vec[0] * np.cos(a) + vec[1] * np.sin(a), -vec[0] * np.sin(a) + vec[1] * np.cos(a), vec[2]])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def prism(x, along1: float, along2: float, across1: float, across2: float, top: float, bottom: float,
|
|
158
|
+
susceptibility: float, ambient: Ambient, azimuth: float = 0.0, height: float = 0.0, remanence=None):
|
|
159
|
+
"""Right rectangular prism (room, foundation, buried block, tank) with sides parallel and perpendicular to the
|
|
160
|
+
profile: [along1, along2] along it, [across1, across2] across it, from ``top`` to ``bottom`` depth.
|
|
161
|
+
|
|
162
|
+
From the gravity gradient tensor by Poisson's relation, B = mu0 / (4 pi) * T M with T per unit G * drho
|
|
163
|
+
(Blakely 1996, §5.2; equivalent to Bhattacharyya 1964).
|
|
164
|
+
"""
|
|
165
|
+
from .gravity import prism_tensor
|
|
166
|
+
|
|
167
|
+
if top + height <= 0:
|
|
168
|
+
raise ValueError("the body must lie below the sensor (raise the sensor or bury the body)")
|
|
169
|
+
m = _to_profile_frame(magnetisation(susceptibility, ambient, remanence), azimuth)
|
|
170
|
+
f = _to_profile_frame(unit_vector(ambient.inclination, ambient.declination), azimuth)
|
|
171
|
+
t = prism_tensor(np.asarray(x, float), 0.0, along1, along2, across1, across2, top + height, bottom + height)
|
|
172
|
+
return MU0 / (4 * np.pi) * np.einsum("i,...ij,j->...", f, t, m) * T_TO_NT
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def vertical_cylinder(x, top: float, bottom: float, radius: float, susceptibility: float, ambient: Ambient,
|
|
176
|
+
azimuth: float = 0.0, height: float = 0.0, remanence=None):
|
|
177
|
+
"""Vertical cylinder (shaft, well, sinkhole pipe) from ``top`` to ``bottom`` depth, axis on the profile at x = 0.
|
|
178
|
+
|
|
179
|
+
Exact: from the cylinder's gravity gradient tensor (``gravity.vertical_cylinder_tensor``) by Poisson's relation,
|
|
180
|
+
B = mu0 / (4 pi) * T M, as for the prism.
|
|
181
|
+
"""
|
|
182
|
+
from .gravity import vertical_cylinder_tensor
|
|
183
|
+
|
|
184
|
+
if top + height <= 0:
|
|
185
|
+
raise ValueError("the body must lie below the sensor (raise the sensor or bury the body)")
|
|
186
|
+
m = _to_profile_frame(magnetisation(susceptibility, ambient, remanence), azimuth)
|
|
187
|
+
f = _to_profile_frame(unit_vector(ambient.inclination, ambient.declination), azimuth)
|
|
188
|
+
t = vertical_cylinder_tensor(np.asarray(x, float), 0.0, radius, top + height, bottom + height)
|
|
189
|
+
return MU0 / (4 * np.pi) * np.einsum("i,...ij,j->...", f, t, m) * T_TO_NT
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def contact(x, top: float, bottom: float, susceptibility: float, ambient: Ambient, azimuth: float = 0.0,
|
|
193
|
+
edge: float = 0.0, height: float = 0.0, remanence=None, far: float = 1e9):
|
|
194
|
+
"""2D vertical contact: a slab from ``top`` to ``bottom`` depth for x > ``edge`` (fault, edge of a fill or of a
|
|
195
|
+
magnetic unit). The slab is closed ``far`` metres away, where its effect is negligible."""
|
|
196
|
+
if not 0 <= top < bottom:
|
|
197
|
+
raise ValueError("need 0 <= top < bottom")
|
|
198
|
+
verts = [(edge, top), (edge + far, top), (edge + far, bottom), (edge, bottom)]
|
|
199
|
+
return polygon(x, verts, susceptibility, ambient, azimuth, height, remanence)
|