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,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)