Coverage for /usr/local/lib/python3.10/site-packages/spam/orientations/analyse.py: 95%

141 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-24 17:26 +0000

1import numpy 

2import scipy 

3import scipy.special 

4from scipy.stats import chi2 

5 

6 

7def fitVonMisesFisher(orientations, confVMF=None, confMu=None, confKappa=None): 

8 """ 

9 This function fits a vonMises-Fisher distribution to a set of N 3D unit vectors. 

10 The distribution is characterised by a mean orientation mu and a spread parameter kappa. 

11 

12 Parameters 

13 ----------- 

14 orientations : Nx3 array of floats 

15 Z, Y and X components of each vector. 

16 

17 confVMF : float 

18 Confidence interval for the test on the vMF distribution 

19 Used for checking wheter the data can be modeled with a vMF distribution 

20 Default = 95% 

21 

22 confMu : float 

23 Confidence interval for the test on the mean orientation mu 

24 Used for computing the error on the mean orientation 

25 Default = 95% 

26 

27 confKappa : float 

28 Confidence interval for the test on kappa 

29 Used for computing the error on kappa 

30 Default = 95% 

31 

32 Returns 

33 -------- 

34 Dictionary containing: 

35 

36 Keys: 

37 orientations : Nx3 array of floats 

38 Z, Y and X components of each vector that is located in the same quadrant as the mean orientation 

39 

40 mu : 1x3 array of floats 

41 Z, Y and X components of mean orientation. 

42 

43 theta : float 

44 Inclination angle of the mean orientation in degrees - angle with the Z axis 

45 

46 alpha : float 

47 Azimuth angle of the mean orientation in degrees - angle in the X-Y plane 

48 

49 R : float 

50 Mean resultant length 

51 First order measure of concentration (ranging between 0 and 1) 

52 

53 kappa : int 

54 Spread of the distribution, must be > 0. 

55 Higher values of kappa mean a higher concentration along the main orientation 

56 

57 vectorsProj : Nx3 array of floats 

58 Z, Y and X components of each vector projected along the mean orientation 

59 

60 fisherTest : bool 

61 Boolean representing the result of the test on the vMF distribution 

62 1 = The data can be modeled with a vMF distribution 

63 0 = The data cannot be modeled with a vMF distribution 

64 

65 fisherTestVal : float 

66 Value to be compared against the critical value, taken from a Chi-squared distrition 

67 

68 muTest : float 

69 Error associated to the mean orientation 

70 Defined as the semi-vertical angle of the cone that comprises the distribution 

71 

72 kappaTest : 1x2 list of floats 

73 Maximum and minimum value of kappa, given the confidence interval 

74 

75 Notes 

76 ----- 

77 

78 The calculation of kappa implemented from Tanabe, A., et al., (2007). Parameter estimation for von Mises_Fisher distributions. doi: 10.1007/s00180-007-0030-7 

79 

80 """ 

81 

82 # Check that the vectors are 3D 

83 assert orientations.shape[1] == 3, "\n spam.orientations.fitVonMisesFisher: The vectors must be an array of Nx3" 

84 

85 # If needed, assign confidence intervals 

86 if confVMF is None: 

87 confVMF = 0.95 

88 if confMu is None: 

89 confMu = 0.95 

90 if confKappa is None: 

91 confKappa = 0.95 

92 # Check the values of the confidence intervals 

93 assert confVMF > 0 and confVMF < 1, "\n spam.orientations.fitVonMisesFisher: The confidence interval for confVMF should be between 0 and 1" 

94 assert confMu > 0 and confMu < 1, "\n spam.orientations.fitVonMisesFisher: The confidence interval for confMu should be between 0 and 1" 

95 assert confKappa > 0 and confKappa < 1, "\n spam.orientations.fitVonMisesFisher: The confidence interval for confKappa should be between 0 and 1" 

96 

97 # Create result dictionary 

98 res = {} 

99 # Remove possible vectors [0, 0, 0] 

100 orientations = orientations[numpy.where(numpy.sum(orientations, axis=1) != 0)[0]] 

101 # Normalize all the vectors from http://stackoverflow.com/questions/2850743/numpy-how-to-quickly-normalize-many-vectors 

102 norms = numpy.apply_along_axis(numpy.linalg.norm, 1, orientations) 

103 orientations = orientations / norms.reshape(-1, 1) 

104 # Read Number of Points 

105 numberOfPoints = orientations.shape[0] 

106 # 1. Get raw-orientation with SVD and flip accordingly 

107 vectSVD = meanOrientation(orientations) 

108 # Flip accordingly to main direction 

109 for i in range(numberOfPoints): 

110 vect = orientations[i, :] 

111 # Compute angle between both vectors 

112 delta1 = numpy.degrees(numpy.arccos((numpy.dot(vectSVD, vect)) / (numpy.linalg.norm(vectSVD) * numpy.linalg.norm(vect)))) 

113 delta2 = numpy.degrees(numpy.arccos((numpy.dot(vectSVD, -1 * vect)) / (numpy.linalg.norm(vectSVD) * numpy.linalg.norm(-1 * vect)))) 

114 if delta1 < delta2: 

115 orientations[i, :] = vect 

116 else: 

117 orientations[i, :] = -1 * vect 

118 res.update({"orientations": orientations}) 

119 # 2. Compute parameters of vMF 

120 # Compute mean orientation 

121 mu = numpy.sum(orientations, axis=0) / numpy.linalg.norm(numpy.sum(orientations, axis=0)) 

122 res.update({"mu": mu}) 

123 # Decompose mean orientation into polar coordinates 

124 thetaR = numpy.arccos(mu[0]) 

125 alphaR = numpy.arctan2(mu[1], mu[2]) 

126 if alphaR < 0: 

127 alphaR = alphaR + 2 * numpy.pi 

128 res.update({"theta": numpy.degrees(thetaR)}) 

129 res.update({"alpha": numpy.degrees(alphaR)}) 

130 # Compute mean resultant length 

131 R = numpy.linalg.norm(numpy.sum(orientations, axis=0)) / numberOfPoints 

132 res.update({"R": R}) 

133 # Compute rotation matrix - needed for projecting all vector around mu 

134 # Taken from pg 194 from MardiaJupp - Fisher book eq. 3.9 is wrong! 

135 rotMatrix = numpy.array( 

136 [ 

137 [ 

138 numpy.cos(thetaR), 

139 numpy.sin(thetaR) * numpy.sin(alphaR), 

140 numpy.sin(thetaR) * numpy.cos(alphaR), 

141 ], 

142 [0, numpy.cos(alphaR), -1 * numpy.sin(alphaR)], 

143 [ 

144 -1 * numpy.sin(thetaR), 

145 numpy.cos(thetaR) * numpy.sin(alphaR), 

146 numpy.cos(thetaR) * numpy.cos(alphaR), 

147 ], 

148 ] 

149 ) 

150 # Project vectors - needed for computing kappa 

151 orientationsProj = numpy.zeros((numberOfPoints, 3)) 

152 for i in range(numberOfPoints): 

153 orientationsProj[i, :] = rotMatrix.dot(orientations[i, :]) 

154 if orientationsProj[i, 0] < 0: 

155 orientationsProj[i, :] = -1 * orientationsProj[i, :] 

156 res.update({"vectorsProj": orientationsProj}) 

157 # Compute Kappa 

158 Z_bar = numpy.sum(orientationsProj[:, 0]) / len(orientationsProj) 

159 Y_bar = numpy.sum(orientationsProj[:, 1]) / len(orientationsProj) 

160 X_bar = numpy.sum(orientationsProj[:, 2]) / len(orientationsProj) 

161 R = numpy.sqrt(Z_bar**2 + Y_bar**2 + X_bar**2) 

162 # First Kappa guess 

163 k_t = R * (3 - 1) / (1 - R**2) 

164 error_i = 5 

165 # Main Iteration 

166 while error_i > 0.001: # t is step i, T is step i+1 

167 I_1 = scipy.special.iv(3 / 2 - 1, k_t) 

168 I_2 = scipy.special.iv(3 / 2, k_t) 

169 k_T = R * k_t * (I_1 / I_2) 

170 error_i = 100 * (numpy.abs(k_T - k_t) / k_t) 

171 k_t = k_T.copy() 

172 # Add results 

173 res.update({"kappa": k_t}) 

174 # 3. Tests 

175 # Test for vMF distribution - Can we really model the data with a vMF distribution? 

176 valCritic = scipy.stats.chi2.ppf(1 - confVMF, 3) 

177 test = 3 * (R**2) / numberOfPoints 

178 if test < valCritic: 

179 fisherFit = True 

180 else: 

181 fisherFit = False 

182 res.update({"fisherTest": fisherFit}) 

183 res.update({"fisherTestVal": test}) 

184 # Test the location of mu - compute the semi-vertical angle of the cone 

185 d = 0 

186 for vect in orientations: 

187 d += (numpy.sum(vect * mu)) ** 2 

188 d = 1 - (1 / numberOfPoints) * d 

189 sigma = numpy.sqrt(d / (numberOfPoints * R**2)) 

190 angle = numpy.degrees(numpy.arcsin(numpy.sqrt(-1 * numpy.log(1 - confMu)) * sigma)) 

191 res.update({"muTest": angle}) 

192 # Test the value of Kappa - compute interval for Kappa - eq. 5.37 Fisher 

193 kappaDown = 0.5 * chi2.ppf(0.5 * (1 - confKappa), 2 * numberOfPoints - 2) / (numberOfPoints - numberOfPoints * R) 

194 kappaUp = 0.5 * chi2.ppf(1 - 0.5 * (1 - confKappa), 2 * numberOfPoints - 2) / (numberOfPoints - numberOfPoints * R) 

195 res.update({"kappaTest": [kappaDown, kappaUp]}) 

196 return res 

197 

198 

199def meanOrientation(orientations): 

200 """ 

201 This function performs a Singular Value Decomposition (SVD) on a series of 3D unit vectors to find the main direction of the set 

202 

203 Parameters 

204 ----------- 

205 orientations : Nx3 numpy array of floats 

206 Z, Y and X components of direction vectors. 

207 Non-unit vectors are normalised. 

208 

209 Returns 

210 -------- 

211 orientationVector : 1x3 numpy arrayRI*numpy.cos(thetaI) of floats 

212 Z, Y and X components of direction vector. 

213 

214 Notes 

215 ----- 

216 Implementation taken from https://www.ltu.se/cms_fs/1.51590!/svd-fitting.pdf 

217 

218 """ 

219 

220 # Read Number of Points 

221 orientations.shape[0] 

222 # Remove possible vectors [0, 0, 0] 

223 orientations = orientations[numpy.where(numpy.sum(orientations, axis=1) != 0)[0]] 

224 # Normalise all the vectors from http://stackoverflow.com/questions/2850743/numpy-how-to-quickly-normalize-many-vectors 

225 norms = numpy.apply_along_axis(numpy.linalg.norm, 1, orientations) 

226 orientations = orientations / norms.reshape(-1, 1) 

227 # Include the negative part 

228 orientationsSVD = numpy.concatenate((orientations, -1 * orientations), axis=0) 

229 # Compute the centre 

230 meanVal = numpy.mean(orientationsSVD, axis=0) 

231 # Center array 

232 orientationsCenteredSVD = orientationsSVD - meanVal 

233 # Run SVD 

234 svd = numpy.linalg.svd(orientationsCenteredSVD.T, full_matrices=False) 

235 # Principal direction 

236 orientationVector = svd[0][:, 0] 

237 # Flip (if needed) to have a positive Z value 

238 if orientationVector[0] < 0: 

239 orientationVector = -1 * orientationVector 

240 return orientationVector 

241 

242 

243def fabricTensor(orientations): 

244 """ 

245 Calculation of a second order fabric tensor from 3D unit vectors representing orientations 

246 

247 Parameters 

248 ---------- 

249 orientations: Nx3 array of floats 

250 Z, Y and X components of direction vectors 

251 Non-unit vectors are normalised. 

252 

253 Returns 

254 ------- 

255 N: 3x3 array of floats 

256 normalised second order fabric tensor 

257 with N[0,0] corresponding to z-z, N[1,1] to y-y and N[2,2] x-x 

258 

259 F: 3x3 array of floats 

260 fabric tensor of the third kind (deviatoric part) 

261 with F[0,0] corresponding to z-z, F[1,1] to y-y and F[2,2] x-x 

262 

263 a: float 

264 scalar anisotropy factor based on the deviatoric part F 

265 

266 Note 

267 ---- 

268 see [Kanatani, 1984] for more information on the fabric tensor 

269 and [Gu et al, 2017] for the scalar anisotropy factor 

270 

271 Function contibuted by Max Wiebicke (Dresden University) 

272 """ 

273 # from http://stackoverflow.com/questions/2850743/numpy-how-to-quickly-normalize-many-vectors 

274 norms = numpy.apply_along_axis(numpy.linalg.norm, 1, orientations) 

275 orientations = orientations / norms.reshape(-1, 1) 

276 # create an empty array 

277 N = numpy.zeros((3, 3)) 

278 size = len(orientations) 

279 for i in range(size): 

280 orientation = orientations[i] 

281 tensProd = numpy.outer(orientation, orientation) 

282 N[:, :] = N[:, :] + tensProd 

283 # fabric tensor of the first kind 

284 N = N / size 

285 # fabric tensor of third kind 

286 F = (N - (numpy.trace(N) * (1.0 / 3.0)) * numpy.eye(3, 3)) * (15.0 / 2.0) 

287 # scalar anisotropy factor 

288 a = numpy.sqrt(3.0 / 2.0 * numpy.tensordot(F, F, axes=2)) 

289 

290 return N, F, a 

291 

292 

293def projectOrientation(vector, coordSystem, projectionSystem): 

294 """ 

295 This functions projects a 3D vector from a given coordinate system into a 2D plane given by a defined projection. 

296 

297 Parameters 

298 ---------- 

299 vector: 1x3 array of floats 

300 Vector to be projected 

301 For cartesian system: ZYX 

302 For spherical system: r, tetha (inclination), phi (azimuth) in Radians 

303 

304 coordSystem: string 

305 Coordinate system of the vector 

306 Either "cartesian" or "spherical" 

307 

308 projectionSystem : string 

309 Projection to be used 

310 Either "lambert", "stereo" or "equidistant" 

311 

312 Returns 

313 ------- 

314 projection_xy: 1x2 array of floats 

315 X and Y coordinates of the projected vector 

316 

317 projection_theta_r: 1x2 array of floats 

318 Theta and R coordinates of the projected vector in radians 

319 

320 """ 

321 

322 projection_xy_local = numpy.zeros(2) 

323 projection_theta_r_local = numpy.zeros(2) 

324 

325 # Reshape the vector and check for errors in shape 

326 try: 

327 vector = numpy.reshape(vector, (3, 1)) 

328 except Exception: 

329 print("\n spam.orientations.projectOrientation: The vector must be an array of 1x3") 

330 return 

331 

332 if coordSystem == "spherical": 

333 # unpack vector 

334 

335 r, theta, phi = vector 

336 

337 x = r * numpy.sin(theta) * numpy.cos(phi) 

338 y = r * numpy.sin(theta) * numpy.sin(phi) 

339 z = r * numpy.cos(theta) 

340 

341 elif coordSystem == "cartesian": 

342 

343 # unpack vector 

344 z, y, x = vector 

345 # we're in cartesian coordinates, (x-y-z mode) Calculate spherical coordinates 

346 # passing to 3d spherical coordinates too... 

347 # From: https://en.wikipedia.org/wiki/Spherical_coordinate_system 

348 # Several different conventions exist for representing the three coordinates, and for the order in which they should be written. 

349 # The use of (r, θ, φ) to denote radial distance, inclination (or elevation), and azimuth, respectively, 

350 # is common practice in physics, and is specified by ISO standard 80000-2 :2009, and earlier in ISO 31-11 (1992). 

351 r = numpy.sqrt(x**2 + y**2 + z**2) 

352 theta = numpy.arccos(z / r) # inclination 

353 phi = numpy.arctan2(y, x) # azimuth 

354 

355 else: 

356 print("\n spam.orientations.projectOrientation: Wrong coordinate system") 

357 return 

358 

359 if projectionSystem == "lambert": # dividing by sqrt(2) so that we're projecting onto a unit circle 

360 projection_xy_local[0] = x * (numpy.sqrt(2 / (1 + z))) 

361 projection_xy_local[1] = y * (numpy.sqrt(2 / (1 + z))) 

362 

363 # sperhical coordinates -- CAREFUL as per this wikipedia page: https://en.wikipedia.org/wiki/Lambert_azimuthal_equal-area_projection 

364 # the symbols for inclination and azimuth ARE INVERTED WITH RESPEST TO THE SPHERICAL COORDS!!! 

365 projection_theta_r_local[0] = phi 

366 # HACK: doing numpy.pi - angle in order for the +z to be projected to 0,0 

367 projection_theta_r_local[1] = 2 * numpy.cos((numpy.pi - theta) / 2) 

368 

369 # cylindrical coordinates 

370 # projection_theta_r_local[0] = phi 

371 # projection_theta_r_local[1] = numpy.sqrt( 2.0 * ( 1 + z ) ) 

372 

373 elif projectionSystem == "stereo": 

374 projection_xy_local[0] = x / (1 - z) 

375 projection_xy_local[1] = y / (1 - z) 

376 

377 # https://en.wikipedia.org/wiki/Stereographic_projection uses a different standard from the page on spherical coord Spherical_coordinate_system 

378 projection_theta_r_local[0] = phi 

379 # HACK: doing numpy.pi - angle in order for the +z to be projected to 0,0 

380 # HACK: doing numpy.pi - angle in order for the +z to be projected to 0,0 

381 projection_theta_r_local[1] = numpy.sin(numpy.pi - theta) / (1 - numpy.cos(numpy.pi - theta)) 

382 

383 elif projectionSystem == "equidistant": 

384 # https://en.wikipedia.org/wiki/Azimuthal_equidistant_projection 

385 # TODO: To be checked, but this looks like it should -- a straight down projection. 

386 projection_xy_local[0] = numpy.sin(phi) 

387 projection_xy_local[1] = numpy.cos(phi) 

388 

389 projection_theta_r_local[0] = phi 

390 projection_theta_r_local[1] = numpy.cos(theta - numpy.pi / 2) 

391 

392 else: 

393 print("\n spam.orientations.projectOrientation: Wrong projection system") 

394 return 

395 

396 return projection_xy_local, projection_theta_r_local