Skip to content

Commit ceda6c0

Browse files
authored
Merge pull request #326 from thurinj/master
Add `from_mij` as an inverse of MTUQ `to_mij`
2 parents 69fe8d6 + 3cf0c79 commit ceda6c0

1 file changed

Lines changed: 194 additions & 0 deletions

File tree

mtuq/util/math.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,200 @@ def to_mij(rho, v, w, kappa, sigma, h):
188188
return np.array([mt0, mt1, mt2, mt3, mt4, mt5])
189189

190190

191+
def from_mij(mij):
192+
"""
193+
Converts from moment tensor (Up-South-East) to lune parameters
194+
This is a stripped out version based of mtpar's cmt2tt function by rmodrak
195+
It ONLY works with MTUQ convention.
196+
197+
Parameters:
198+
-----------
199+
mij : array_like, shape (6,)
200+
Moment tensor components in Up-South-East convention (default MTUQ)
201+
[Mxx, Myy, Mzz, Mxy, Mxz, Myz]
202+
203+
Returns:
204+
--------
205+
tuple : (rho, v, w, kappa, sigma, h)
206+
rho : Tape2012 magnitude parameter
207+
v : Tape2015 parameter v [-1/3, 1/3]
208+
w : Tape2015 parameter w [-3π/8, 3π/8]
209+
kappa : strike angle [0°, 360°]
210+
sigma : rake angle [-90°, 90°]
211+
h : cosine of dip angle [0, 1]
212+
213+
"""
214+
215+
mij = np.array(mij)
216+
217+
# Cast from up-south-east to south-east-up convention (following mtpar)
218+
# USE convention: [Mxx, Myy, Mzz, Mxy, Mxz, Myz] (UP-SOUTH-EAST)
219+
# SEU convention: [Myy, Mzz, Mxx, Myz, Mxy, Mxz] (SOUTH-EAST-UP)
220+
mij_seu = np.array([mij[1], mij[2], mij[0], mij[5], mij[3], mij[4]])
221+
222+
# Convert to matrix representation for eigenvalue decomposition
223+
M_seu = np.array([[mij_seu[0], mij_seu[3], mij_seu[4]],
224+
[mij_seu[3], mij_seu[1], mij_seu[5]],
225+
[mij_seu[4], mij_seu[5], mij_seu[2]]])
226+
227+
# Eigenvalue decomposition (sort eigenvalues highest to lowest)
228+
lam, U = np.linalg.eigh(M_seu)
229+
idx = np.argsort(lam)[::-1] # descending sort
230+
lam = lam[idx]
231+
U = U[:, idx]
232+
233+
# Convert eigenvalues to lune coordinates
234+
# magnitude of lambda vector
235+
lammag = np.linalg.norm(lam)
236+
237+
# seismic moment M0 = ||lam|| / sqrt(2)
238+
M0 = lammag / np.sqrt(2.)
239+
rho = M0 * np.sqrt(2.) # rho parameter
240+
241+
# lune coordinates (gamma, delta)
242+
if np.sum(lam) != 0.:
243+
bdot = np.sum(lam) / (np.sqrt(3) * lammag)
244+
bdot = np.clip(bdot, -1, 1) # clipping to avoid numerical issues
245+
delta = 90. - np.rad2deg(np.arccos(bdot))
246+
else:
247+
delta = 0.
248+
249+
# gamma coordinate
250+
if lam[0] != lam[2]:
251+
gamma = np.rad2deg(np.arctan((-lam[0] + 2.*lam[1] - lam[2]) /
252+
(np.sqrt(3) * (lam[0] - lam[2]))))
253+
else:
254+
gamma = 0.
255+
256+
# Convert lune coordinates to v, w parameters
257+
gamma_rad = np.deg2rad(gamma)
258+
delta_rad = np.deg2rad(delta)
259+
beta = np.pi/2. - delta_rad
260+
261+
v = (1./3.) * np.sin(3. * gamma_rad)
262+
u = (0.75 * beta - 0.5 * np.sin(2. * beta) + 0.0625 * np.sin(4. * beta))
263+
w = 3. * np.pi / 8. - u
264+
265+
# Ensure det(U) = 1
266+
if np.linalg.det(U) < 0:
267+
U[:, 1] *= -1
268+
269+
# 45° rotation around y axis to get the fault vectors from eigenvectors
270+
Y = np.array([[np.cos(np.pi/4), 0, np.sin(np.pi/4)],
271+
[0, 1, 0],
272+
[-np.sin(np.pi/4), 0, np.cos(np.pi/4)]]) # rotmat(45, 1)
273+
274+
V = np.dot(U, Y)
275+
S = V[:, 0] # slip vector
276+
N = V[:, 2] # fault normal
277+
278+
# Round off small values to -/+1 and 0 (like in mtpar)
279+
EPSVAL = 1e-6
280+
S[np.abs(S) < EPSVAL] = 0
281+
N[np.abs(N) < EPSVAL] = 0
282+
S[np.abs(S - 1) < EPSVAL] = 1
283+
S[np.abs(S + 1) < EPSVAL] = -1
284+
N[np.abs(N - 1) < EPSVAL] = 1
285+
N[np.abs(N + 1) < EPSVAL] = -1
286+
287+
# Calculate fault angles using south-east-up basis
288+
zenith = np.array([0, 0, 1])
289+
north = np.array([-1, 0, 0])
290+
291+
def faultvec2angles(S_vec, N_vec):
292+
"""Calculate fault angles from slip and normal vectors
293+
Similar to Ryan's mtpar implementation. I keep within the scope of
294+
the from_mij function because it is only useful there."""
295+
# Strike vector
296+
v_cross = np.cross(zenith, N_vec)
297+
if np.linalg.norm(v_cross) == 0:
298+
K = S_vec # horizontal fault case
299+
else:
300+
K = v_cross / np.linalg.norm(v_cross)
301+
302+
# Strike angle (kappa)
303+
def fangle_signed(va, vb, vnor):
304+
# Angle between two vectors with sign
305+
xy = np.dot(va, vb)
306+
xx = np.dot(va, va)
307+
yy = np.dot(vb, vb)
308+
theta = np.rad2deg(np.arccos(np.clip(xy / (xx * yy)**0.5, -1, 1)))
309+
310+
if abs(theta - 180) <= EPSVAL:
311+
return 180
312+
else:
313+
Dmat = np.column_stack([va, vb, vnor])
314+
if np.linalg.det(Dmat) < 0:
315+
return -theta
316+
else:
317+
return theta
318+
319+
kappa = fangle_signed(north, K, -zenith)
320+
kappa = kappa % 360. # wrap to [0, 360)
321+
322+
# Dip angle (theta)
323+
costh = np.dot(N_vec, zenith)
324+
theta = np.rad2deg(np.arccos(np.clip(costh, -1, 1)))
325+
326+
# Rake angle (sigma)
327+
sigma = fangle_signed(K, S_vec, N_vec)
328+
329+
return theta, sigma, kappa, K
330+
331+
# Frame2angles: evaluate four combinations to resolve ambiguity
332+
# There are four combinations of N and S that represent a double couple
333+
# moment tensor (TT2012, Fig. 15). We need to find the one within the
334+
# proper bounding region (TT2012, Figs. 16, B1)
335+
336+
# Four combinations for the given frame
337+
S1, N1 = S, N
338+
S2, N2 = -S, -N
339+
S3, N3 = N, S
340+
S4, N4 = -N, -S
341+
342+
# Calculate fault angles for each combination
343+
theta1, sigma1, kappa1, K1 = faultvec2angles(S1, N1)
344+
theta2, sigma2, kappa2, K2 = faultvec2angles(S2, N2)
345+
theta3, sigma3, kappa3, K3 = faultvec2angles(S3, N3)
346+
theta4, sigma4, kappa4, K4 = faultvec2angles(S4, N4)
347+
348+
theta = np.array([theta1, theta2, theta3, theta4])
349+
sigma = np.array([sigma1, sigma2, sigma3, sigma4])
350+
kappa = np.array([kappa1, kappa2, kappa3, kappa4])
351+
352+
# Which combination lies within the bounding region?
353+
btheta = (theta <= 90. + EPSVAL)
354+
bsigma = (np.abs(sigma) <= 90. + EPSVAL)
355+
bb = np.logical_and(btheta, bsigma)
356+
ii = np.where(bb)[0]
357+
nn = len(ii)
358+
359+
if nn == 0:
360+
raise Exception('No valid fault plane found within bounding region')
361+
elif nn == 1:
362+
jj = ii[0]
363+
elif nn == 2:
364+
# Choose one of the two based on strike angle
365+
# This is a simplified version of the _pick function in mtpar
366+
if kappa[ii[0]] < 180:
367+
jj = ii[0]
368+
else:
369+
jj = ii[1]
370+
else:
371+
# Take the first one for unusual cases
372+
jj = ii[0]
373+
374+
# Select the angles from the chosen combination
375+
final_theta = theta[jj]
376+
final_sigma = sigma[jj]
377+
final_kappa = kappa[jj]
378+
379+
# Convert theta to h parameter
380+
h = np.cos(np.deg2rad(final_theta))
381+
382+
return (rho, v, w, final_kappa, final_sigma, h)
383+
384+
191385
def to_xyz(F0, phi, h):
192386
""" Converts from spherical to Cartesian coordinates (east-north-up)
193387
"""

0 commit comments

Comments
 (0)