pydrex.minerals
PyDRex: Computations of mineral texture and elasticity.
Acronyms:
- CPO = Crystallographic preferred orientation, i.e. preferential clustering of polycrystal grain orientations in SO(3), leading to an overall anisotropic orientation distribution
1"""> PyDRex: Computations of mineral texture and elasticity. 2 3**Acronyms:** 4- CPO = Crystallographic preferred orientation, 5 i.e. preferential clustering of polycrystal grain orientations in SO(3), 6 leading to an overall anisotropic orientation distribution 7 8""" 9 10import io 11from dataclasses import dataclass, field 12from zipfile import ZipFile 13 14import numpy as np 15from numpy import asarray as Ŋ 16from scipy import linalg as la 17from scipy.integrate import LSODA 18from scipy.spatial.transform import Rotation 19 20from pydrex import core as _core 21from pydrex import exceptions as _err 22from pydrex import io as _io 23from pydrex import logger as _log 24from pydrex import tensors as _tensors 25from pydrex import utils as _utils 26 27OLIVINE_PRIMARY_AXIS = { 28 _core.MineralFabric.olivine_A: "a", 29 _core.MineralFabric.olivine_B: "c", 30 _core.MineralFabric.olivine_C: "c", 31 _core.MineralFabric.olivine_D: "a", 32 _core.MineralFabric.olivine_E: "a", 33} 34"""Primary slip axis name for for the given olivine `fabric`.""" 35 36 37OLIVINE_SLIP_SYSTEMS = ( 38 ([0, 1, 0], [1, 0, 0]), 39 ([0, 0, 1], [1, 0, 0]), 40 ([0, 1, 0], [0, 0, 1]), 41 ([1, 0, 0], [0, 0, 1]), 42) 43"""Slip systems for olivine in conventional order. 44 45Tuples contain the slip plane normal and slip direction vectors. 46The order of slip systems returned matches the order of critical shear stresses 47returned by `pydrex.core.get_crss`. 48 49""" 50 51 52@dataclass 53class StiffnessTensors: 54 olivine: np.ndarray = field( 55 default_factory=lambda: np.array( 56 [ 57 [320.71, 69.84, 71.22, 0.0, 0.0, 0.0], 58 [69.84, 197.25, 74.8, 0.0, 0.0, 0.0], 59 [71.22, 74.8, 234.32, 0.0, 0.0, 0.0], 60 [0.0, 0.0, 0.0, 63.77, 0.0, 0.0], 61 [0.0, 0.0, 0.0, 0.0, 77.67, 0.0], 62 [0.0, 0.0, 0.0, 0.0, 0.0, 78.36], 63 ] 64 ) 65 ) 66 """Stiffness tensor for olivine (Voigt representation), with units of GPa. 67 68 The source of the values used here is unknown, but they are copied 69 from the original DRex code: <http://www.ipgp.fr/~kaminski/web_doudoud/DRex.tar.gz> 70 [88K download] 71 72 """ 73 enstatite: np.ndarray = field( 74 default_factory=lambda: np.array( 75 [ 76 [236.9, 79.6, 63.2, 0.0, 0.0, 0.0], 77 [79.6, 180.5, 56.8, 0.0, 0.0, 0.0], 78 [63.2, 56.8, 230.4, 0.0, 0.0, 0.0], 79 [0.0, 0.0, 0.0, 84.3, 0.0, 0.0], 80 [0.0, 0.0, 0.0, 0.0, 79.4, 0.0], 81 [0.0, 0.0, 0.0, 0.0, 0.0, 80.1], 82 ] 83 ) 84 ) 85 """Stiffness tensor for enstatite (Voigt representation), with units of GPa. 86 87 The source of the values used here is unknown, but they are copied 88 from the original DRex code: <http://www.ipgp.fr/~kaminski/web_doudoud/DRex.tar.gz> 89 [88K download] 90 91 """ 92 93 def __iter__(self): 94 # So that [S for S in StiffnessTensors()] can be indexed with `MineralPhase`s. 95 indexed = { 96 _core.MineralPhase.olivine: self.olivine, 97 _core.MineralPhase.enstatite: self.enstatite, 98 } 99 for _, v in sorted(indexed.items()): 100 yield v 101 102 103def peridotite_solidus(pressure, fit="Hirschmann2000"): 104 """Get peridotite solidus (i.e. melting) temperature based on experimental fits. 105 106 Pressure is expected to be in GPa. 107 108 Supported fits: 109 - ["Hirschmann2000"](https://doi.org/10.1029/2000GC000070) 110 - ["Herzberg2000"](https://doi.org/10.1029/2000GC000089) 111 - ["Duvernay2024"](https://doi.org/10.1029/2023GC011288) 112 113 """ 114 match fit: 115 case "Herzberg2000": 116 return 1086 - 5.7 * pressure + 390 * np.log(pressure) 117 case "Hirschmann2000": 118 return -5.104 * pressure**2 + 132.899 * pressure + 1120.661 119 case "Duvernay2024": 120 return -6.8 * pressure**2 + 141.4 * pressure + 1101.3 121 case _: 122 raise ValueError(f"unsupported fit '{fit}'") 123 124 125@dataclass 126class Mineral: 127 """Class for storing polycrystal texture for a single mineral phase. 128 129 A `Mineral` stores texture data for an aggregate of grains¹. 130 Additionally, mineral fabric type and deformation regime are also tracked. 131 To provide an initial texture for the mineral, use the constructor arguments 132 `fractions_init` and `orientations_init`. By default, 133 a uniform volume distribution of random orientations is generated. 134 135 The `update_orientations` method computes new orientations and grain volumes 136 for a given velocity gradient. These results are stored in the `.orientations` and 137 `.fractions` attributes of the `Mineral` instance. The method also returns the 138 updated macroscopic deformation gradient based on the provided initial deformation 139 gradient. 140 141 ¹Note that the "number of grains" is a static integer value that 142 does not track the actual number of physical grains in the deforming polycrystal. 143 Instead, this number acts as a "number of bins" for the statistical resolution of 144 the crystallographic orientation distribution. The value is roughly equivalent to 145 (a multiple of) the number of initial, un-recrystallised grains in the polycrystal. 146 It is assumed that recrystallised grains do not grow large enough to require 147 rotation tracking. 148 149 **Examples:** 150 151 Mineral with isotropic initial texture: 152 153 >>> import pydrex 154 >>> olA = pydrex.Mineral( 155 ... phase=pydrex.MineralPhase.olivine, 156 ... fabric=pydrex.MineralFabric.olivine_A, 157 ... regime=pydrex.DeformationRegime.matrix_dislocation, 158 ... n_grains=2000 159 ... ) 160 >>> olA.phase 161 <MineralPhase.olivine: 0> 162 >>> olA.fabric 163 <MineralFabric.olivine_A: 0> 164 >>> olA.regime 165 <DeformationRegime.matrix_dislocation: 4> 166 >>> olA.n_grains 167 2000 168 169 Mineral with specified initial texture and default phase, fabric and regime settings 170 which are for an olivine A-type mineral in the dislocation creep regime. 171 The initial grain volume fractions should be normalised. 172 173 >>> import numpy as np 174 >>> from scipy.spatial.transform import Rotation 175 >>> import pydrex 176 >>> rng = np.random.default_rng() 177 >>> n_grains = 2000 178 >>> olA = pydrex.Mineral( 179 ... n_grains=n_grains, 180 ... fractions_init=np.full(n_grains, 1 / n_grains), 181 ... orientations_init=Rotation.from_euler( 182 ... "zxz", [ 183 ... [x * np.pi / 2, np.pi / 2, np.pi / 2] for x in rng.random(n_grains) 184 ... ] 185 ... ).inv().as_matrix(), 186 ... ) 187 >>> len(olA.orientations) 188 1 189 >>> type(olA.orientations) 190 <class 'list'> 191 >>> olA.orientations[0].shape 192 (2000, 3, 3) 193 >>> olA.fractions[0].shape 194 (2000,) 195 196 Note that minerals can also be constructed from serialized data, 197 see `Mineral.load` and `Mineral.from_file`. 198 199 **Attributes:** 200 - `phase` (`pydrex.core.MineralPhase`) — ordinal number of the mineral phase 201 - `fabric` (`pydrex.core.MineralFabric`) — ordinal number of the fabric type 202 - `regime` (`pydrex.core.DeformationRegime`) — ordinal number of the deformation 203 regime 204 - `n_grains` (int) — number of grains in the aggregate 205 - `fractions` (list of arrays) — grain volume fractions for each texture snapshot 206 - `orientations` (list of arrays) — grain orientation matrices for each texture 207 snapshot 208 - `seed` (int) — seed used by the random number generator to set up the isotropic 209 initial condition when `fractions_init` or `orientations_init` are not provided 210 - `lband` (int) — passed to the `scipy.integrate.LSODA` solver 211 - `uband` (int) — passed to the `scipy.integrate.LSODA` solver 212 213 """ 214 215 phase: int = _core.MineralPhase.olivine 216 fabric: int = _core.MineralFabric.olivine_A 217 regime: int = _core.DeformationRegime.matrix_dislocation 218 n_grains: int = _core.DefaultParams().number_of_grains 219 # Initial condition, randomised if not given. 220 fractions_init: np.ndarray | None = None 221 orientations_init: np.ndarray | None = None 222 fractions: list = field(default_factory=list) 223 orientations: list = field(default_factory=list) 224 seed: int | None = None 225 lband: int | None = None 226 uband: int | None = None 227 228 def __str__(self): 229 # String output, used for str(self) and f"{self}", etc. 230 if hasattr(self.fractions[0], "shape"): 231 shape_of_fractions = str(self.fractions[0].shape) 232 else: 233 shape_of_fractions = "(?)" 234 235 if hasattr(self.orientations[0], "shape"): 236 shape_of_orientations = str(self.orientations[0].shape) 237 else: 238 shape_of_orientations = "(?)" 239 240 obj = self.__class__.__qualname__ 241 phase = f"(phase={self.phase!r}, " 242 fabric = f"fabric={self.fabric!r}, " 243 regime = f"regime={self.regime!r}, " 244 n_grains = f"n_grains={self.n_grains}, " 245 _fclass = self.fractions.__class__.__qualname__ 246 _f0class = self.fractions[0].__class__.__qualname__ 247 frac = f"fractions=<{_fclass} of {_f0class} {shape_of_fractions}>, " 248 _oclass = self.orientations.__class__.__qualname__ 249 _o0class = self.orientations[0].__class__.__qualname__ 250 orient = f"orientations=<{_oclass} of {_o0class} {shape_of_orientations}>)" 251 return f"{obj}{phase}{fabric}{regime}{n_grains}{frac}{orient}" 252 253 def _repr_pretty_(self, p, cycle): 254 # Format to use when printing to IPython or other interactive console. 255 p.text(self.__str__() if not cycle else self.__class__.__qualname__ + "(...)") 256 257 def __post_init__(self): 258 """Initialise random orientations and grain volume fractions.""" 259 if self.fractions_init is None: 260 self.fractions_init = np.full(self.n_grains, 1.0 / self.n_grains) 261 if self.orientations_init is None: 262 self.orientations_init = Rotation.random( 263 self.n_grains, random_state=self.seed 264 ).as_matrix() 265 # For large numbers of grains, the number of ODE's exceeds what LSODA can 266 # handle. Therefore, we specify the Jacobian matrix as banded. 267 # By default, we use a bandwidth o f 12000 with lband = uband = 6000. 268 # This should work for up to 10000 grains. 269 if self.lband is None and self.uband is None and self.n_grains > 4632: 270 _log.warning( 271 "using a banded Jacobian because of the large number of grains." 272 + " To manually control the bandwidth, set `lband` and/or `uband`" 273 + f" in calls to `{self.__class__.__qualname__}.update_orientations`." 274 ) 275 self.lband = 6000 276 self.uband = 6000 277 278 # Copy the initial values to the storage lists. 279 self.fractions.append(self.fractions_init) 280 self.orientations.append(self.orientations_init) 281 282 # Delete the initial value duplicates to avoid confusion. 283 del self.fractions_init 284 del self.orientations_init 285 286 _log.info("created %s", self) 287 288 def __eq__(self, other): 289 if other.__class__ is self.__class__: 290 return ( 291 self.phase == other.phase 292 and self.fabric == other.fabric 293 and self.regime == other.regime 294 and self.n_grains == other.n_grains 295 and len(self.fractions) == len(other.fractions) 296 and np.all( 297 Ŋ([f.shape for f in self.fractions]) 298 == Ŋ([f.shape for f in other.fractions]) 299 ) 300 and np.all(Ŋ(self.fractions) == Ŋ(other.fractions)) 301 and len(self.orientations) == len(other.orientations) 302 and np.all( 303 Ŋ([f.shape for f in self.orientations]) 304 == Ŋ([f.shape for f in other.orientations]) 305 ) 306 and np.all(Ŋ(self.orientations) == Ŋ(other.orientations)) 307 and self.seed == other.seed 308 and self.lband == other.lband 309 and self.uband == other.uband 310 ) 311 return False 312 313 def update_orientations( 314 self, 315 params: dict, 316 deformation_gradient: np.ndarray, 317 get_velocity_gradient, 318 pathline: tuple, 319 get_regime=None, 320 **kwargs, 321 ) -> np.ndarray: 322 """Update orientations and volume distribution for the `Mineral`. 323 324 Update crystalline orientations and grain volume distribution 325 for minerals undergoing plastic deformation. Return the updated deformation 326 gradient measuring the corresponding macroscopic deformation. 327 328 .. note:: For multi-phase simulations, the return value should only be collected 329 for the last phase, i.e. the last call to `update_orientations`. This value 330 should be passed on to subsequent calls to `update_orientations` as the 331 `deformation_gradient` argument. 332 333 - `params` — dictionary with the same keys as the return value of 334 `pydrex.core.DefaultParams.as_dict()` 335 - `deformation_gradient` — 3x3 deformation gradient tensor 336 - `get_velocity_gradient` — callable with signature `f(t, x)` that returns a 3x3 337 velocity gradient matrix at time `t` and position `x` (3D vector) 338 - `pathline` — tuple consisting of: 339 1. the time at which to start the CPO integration 340 2. the time at which to stop the CPO integration 341 3. a callable with signature `f(t)` that returns the position of the mineral 342 at time `t` where $t ∈ [t_{start}, t_{end}]$ 343 - `get_regime` (optional) — callable with signature f(t, x) that returns a 344 `pydrex.core.DeformationRegime` declaring the dominant deformation mechanism 345 at time `t` and position `x` (3D vector) 346 347 Any additional (optional) keyword arguments are passed to 348 `scipy.integrate.LSODA`. 349 350 Array values must provide a NumPy-compatible interface: 351 <https://numpy.org/doc/stable/user/whatisnumpy.html> 352 353 Examples: 354 355 >>> from itertools import pairwise 356 >>> import pydrex 357 >>> olA = pydrex.Mineral( 358 ... phase=pydrex.MineralPhase.olivine, 359 ... fabric=pydrex.MineralFabric.olivine_A, 360 ... regime=pydrex.DeformationRegime.matrix_dislocation, 361 ... n_grains=pydrex.DefaultParams().number_of_grains, 362 ... ) 363 >>> def get_velocity_gradient(t, x): # Simple L for simple shear. 364 ... L = np.zeros((3, 3)) 365 ... L[0, 2] = 1 366 ... return L 367 >>> def get_position(t): 368 ... return np.zeros(3) # Stationary polycrystal for this example. 369 >>> timestamps = range(2) # Just 1 timestep for demonstration. 370 >>> deformation_gradient = np.eye(3) # Start with an undeformed polycrystal. 371 >>> for t_start, t_end in pairwise(timestamps): 372 ... # Update deformation_gradient, olA.orientations and olA.fractions. 373 ... deformation_gradient = olA.update_orientations( 374 ... pydrex.DefaultParams().as_dict(), 375 ... deformation_gradient, 376 ... get_velocity_gradient, 377 ... (t_start, t_end, get_position), 378 ... ) 379 >>> from numpy import testing as nt 380 >>> nt.assert_allclose(deformation_gradient, 381 ... np.eye(3) + get_velocity_gradient(t_end, np.nan), 382 ... atol=1e-15, rtol=0 383 ... ) 384 385 """ 386 387 # ===== Set up callables for the ODE solver and internal processing ===== 388 389 def eval_rhs(t, y): 390 """Evaluate right hand side of the D-Rex PDE.""" 391 # assert not np.any(np.isnan(y)), y[np.isnan(y)].shape 392 position = get_position(t) 393 velocity_gradient = get_velocity_gradient(t, position) 394 # _log.debug( 395 # "calculating CPO at %s (t=%e) with velocity gradient %s", 396 # position, 397 # t, 398 # velocity_gradient.ravel(), 399 # ) 400 401 if self.phase not in params["phase_assemblage"]: 402 # Warning rather than failure, so that we tolerate loops like: 403 # [m.update_orientations(...) for m in minerals] 404 # Where some minerals in the list might be (temporarily) superfluous. 405 _log.warning( 406 "skipping %s mineral, phase omitted from configuration", self.phase 407 ) 408 return 409 410 if get_regime is not None: 411 self.regime = get_regime(t, position) 412 413 try: 414 volume_fraction = params["phase_fractions"][ 415 params["phase_assemblage"].index(self.phase) 416 ] 417 except IndexError: 418 raise ValueError( 419 f"phase must be a valid `MineralPhase`, not {self.phase}" 420 ) 421 422 strain_rate = (velocity_gradient + velocity_gradient.transpose()) / 2 423 strain_rate_max = np.abs(la.eigvalsh(strain_rate)).max() 424 deformation_gradient, orientations, fractions = _utils.extract_vars( 425 y, self.n_grains 426 ) 427 deformation_gradient_diff = velocity_gradient @ deformation_gradient 428 deformation_gradient_spin = _tensors.polar_decompose( 429 deformation_gradient_diff 430 )[1] 431 # Uses nondimensional values of strain rate and velocity gradient. 432 orientations_diff, fractions_diff = _core.derivatives( 433 regime=self.regime, 434 phase=self.phase, 435 fabric=self.fabric, 436 n_grains=self.n_grains, 437 orientations=orientations, 438 fractions=fractions, 439 strain_rate=strain_rate / strain_rate_max, 440 velocity_gradient=velocity_gradient / strain_rate_max, 441 deformation_gradient_spin=deformation_gradient_spin, 442 stress_exponent=params["stress_exponent"], 443 deformation_exponent=params["deformation_exponent"], 444 nucleation_efficiency=params["nucleation_efficiency"], 445 gbm_mobility=params["gbm_mobility"], 446 volume_fraction=volume_fraction, 447 ) 448 return np.hstack( 449 ( 450 deformation_gradient_diff.flatten(), 451 orientations_diff.flatten() * strain_rate_max, 452 fractions_diff * strain_rate_max, 453 ) 454 ) 455 456 def perform_step(solver): 457 """Perform SciPy solver step and appropriate processing.""" 458 message = solver.step() 459 if message is not None and solver.status == "failed": 460 raise _err.IterationError(message) 461 # _log.debug( 462 # "%s step_size=%e", solver.__class__.__qualname__, solver.step_size 463 # ) 464 465 deformation_gradient, orientations, fractions = _utils.extract_vars( 466 solver.y, self.n_grains 467 ) 468 orientations, fractions = _utils.apply_gbs( 469 orientations, 470 fractions, 471 params["gbs_threshold"], 472 self.orientations[-1], 473 self.n_grains, 474 ) 475 solver.y[9:] = np.hstack((orientations.flatten(), fractions)) 476 477 # ===== Initialise and run the solver using the above callables ===== 478 479 time_start, time_end, get_position = pathline 480 if not callable(get_velocity_gradient): 481 raise ValueError( 482 "unable to evaluate velocity gradient callable." 483 + " You must provide a callable with signature f(t, x)" 484 + " that returns a 3x3 matrix." 485 ) 486 if not callable(get_position): 487 raise ValueError( 488 "unable to evaluate position callable." 489 + " You must provide a callable with signature f(t)" 490 + " that returns a 3-component array." 491 ) 492 _log.debug( 493 "calculating CPO from %s (t=%s) to %s (t=%s)", 494 get_position(time_start), 495 time_start, 496 get_position(time_end), 497 time_end, 498 ) 499 _log.debug(" with deformation gradient %s", deformation_gradient.ravel()) 500 _log.debug( 501 " with velocity gradient interpolated between %s and %s", 502 get_velocity_gradient(time_start, get_position(time_start)).ravel(), 503 get_velocity_gradient(time_end, get_position(time_end)).ravel(), 504 ) 505 _log.debug( 506 " intermediate velocity gradient = %s", 507 get_velocity_gradient( 508 (time_start + time_end) / 2, get_position((time_start + time_end) / 2) 509 ).ravel(), 510 ) 511 512 y_start = np.hstack( 513 ( 514 deformation_gradient.flatten(), 515 self.orientations[-1].flatten(), 516 self.fractions[-1], 517 ) 518 ) 519 solver = LSODA( 520 eval_rhs, 521 time_start, 522 y_start, 523 time_end, 524 atol=kwargs.pop("atol", np.abs(y_start * 1e-6) + 1e-12), 525 rtol=kwargs.pop("rtol", 1e-6), 526 first_step=kwargs.pop("first_step", np.abs(time_end - time_start) * 1e-1), 527 # max_step=kwargs.pop("max_step", np.abs(time_end - time_start)), 528 lband=self.lband, 529 uband=self.uband, 530 **kwargs, 531 ) 532 perform_step(solver) 533 while solver.status == "running": 534 perform_step(solver) 535 536 # Extract final values for this simulation step, append to storage. 537 deformation_gradient, orientations, fractions = _utils.extract_vars( 538 solver.y.squeeze(), self.n_grains 539 ) 540 self.orientations.append(orientations) 541 self.fractions.append(fractions) 542 return deformation_gradient 543 544 def save(self, filename, postfix=None): 545 """Save CPO data for all stored timesteps to a `numpy` NPZ file. 546 547 If `postfix` is not `None`, the data is appended to the NPZ file 548 in fields ending with "`_postfix`". 549 550 Raises a `ValueError` if the data shapes are not compatible. 551 552 See also: `numpy.savez`, `Mineral.load`, `Mineral.from_file`. 553 554 """ 555 if len(self.fractions) != len(self.orientations): 556 raise ValueError( 557 "Length of stored results must match." 558 + " You've supplied currupted data with:\n" 559 + f"- {len(self.fractions)} grain size results, and\n" 560 + f"- {len(self.orientations)} orientation results." 561 ) 562 if self.fractions[0].shape[0] == self.orientations[0].shape[0] == self.n_grains: 563 data = { 564 "meta": np.array( 565 [self.phase, self.fabric, self.regime], dtype=np.uint8 566 ), 567 "fractions": np.stack(self.fractions), 568 "orientations": np.stack(self.orientations), 569 } 570 # Create parent directories, resolve relative paths. 571 _io.resolve_path(filename) 572 # Append to file, requires postfix (unique name). 573 if postfix is not None: 574 _log.info("saving Mineral to file %s (postfix: %s)", filename, postfix) 575 archive = ZipFile(filename, mode="a", allowZip64=True) 576 for key in data.keys(): 577 with archive.open( 578 f"{key}_{postfix}", "w", force_zip64=True 579 ) as file: 580 buffer = io.BytesIO() 581 np.save(buffer, data[key]) 582 file.write(buffer.getvalue()) 583 buffer.close() 584 else: 585 _log.info("saving Mineral to file %s", filename) 586 np.savez(filename, **data) 587 else: 588 raise ValueError( 589 "Size of CPO data arrays must match number of grains." 590 + " You've supplied corrupted data with:\n" 591 + f"- `n_grains = {self.n_grains}`,\n" 592 + f"- `fractions[0].shape = {self.fractions[0].shape}`, and\n" 593 + f"- `orientations[0].shape = {self.orientations[0].shape}`." 594 ) 595 596 def load(self, filename, postfix=None): 597 """Load CPO data from a `numpy` NPZ file. 598 599 If `postfix` is not `None`, data is read from fields ending with "`_postfix`". 600 601 See also: `Mineral.save`, `Mineral.from_file`. 602 603 """ 604 if not filename.endswith(".npz"): 605 raise ValueError( 606 f"Must only load from numpy NPZ format. Cannot load from {filename}." 607 ) 608 data = np.load(filename) 609 if postfix is not None: 610 phase, fabric, regime = data[f"meta_{postfix}"] 611 self.fractions = list(data[f"fractions_{postfix}"]) 612 self.orientations = list(data[f"orientations_{postfix}"]) 613 else: 614 phase, fabric, regime = data["meta"] 615 self.fractions = list(data["fractions"]) 616 self.orientations = list(data["orientations"]) 617 618 self.phase = phase 619 self.fabric = fabric 620 self.regime = regime 621 self.orientations_init = self.orientations[0] 622 self.fractions_init = self.fractions[0] 623 624 @classmethod 625 def from_file(cls, filename, postfix=None): 626 """Construct a `Mineral` instance using data from a `numpy` NPZ file. 627 628 If `postfix` is not `None`, data is read from fields ending with “`_postfix`”. 629 630 See also: `Mineral.save`, `Mineral.load`. 631 632 """ 633 if not filename.endswith(".npz"): 634 raise ValueError( 635 f"Must only load from numpy NPZ format. Cannot load from {filename}." 636 ) 637 data = np.load(filename) 638 if postfix is not None: 639 phase, fabric, regime = data[f"meta_{postfix}"] 640 fractions = list(data[f"fractions_{postfix}"]) 641 orientations = list(data[f"orientations_{postfix}"]) 642 else: 643 phase, fabric, regime = data["meta"] 644 fractions = list(data["fractions"]) 645 orientations = list(data["orientations"]) 646 647 mineral = cls( 648 phase, 649 fabric, 650 regime, 651 n_grains=len(fractions[0]), 652 fractions_init=fractions[0], 653 orientations_init=orientations[0], 654 ) 655 mineral.fractions = fractions 656 mineral.orientations = orientations 657 return mineral 658 659 660def update_all( 661 minerals: list[Mineral], 662 params: dict, 663 deformation_gradient: np.ndarray, 664 get_velocity_gradient, 665 pathline: tuple, 666 get_regime=None, 667 **kwargs, 668) -> np.ndarray: 669 """Update orientations and volume distributions for all mineral phases. 670 671 Returns the updated deformation gradient tensor which measures the accumulated 672 macroscopic strain. 673 674 """ 675 for i, mineral in enumerate(minerals): 676 # Deformation gradient is independent of mineral phase. 677 new_deformation_gradient = mineral.update_orientations( 678 params=params, 679 deformation_gradient=deformation_gradient, 680 get_velocity_gradient=get_velocity_gradient, 681 pathline=pathline, 682 get_regime=get_regime, 683 **kwargs, 684 ) 685 return new_deformation_gradient 686 687 688# TODO: Compare to [Man & Huang, 2011](https://doi.org/10.1007/s10659-011-9312-y). 689def voigt_averages( 690 minerals: list[Mineral], 691 phase_assemblage: list[_core.MineralPhase], 692 phase_fractions: list[float], 693 elastic_tensors: StiffnessTensors = StiffnessTensors(), 694): 695 """Calculate elastic tensors as the Voigt averages of a collection of `mineral`s. 696 697 - `minerals` — mineral phases storing orientations and fractional volumes of grains 698 - `phase_assemblage` — collection of unique mineral phases in the aggregate 699 - `phase_fractions` — collection of volume fractions for each phase in 700 `phase_assemblage` (values should sum to 1). 701 702 Raises a ValueError if the minerals contain an unequal number of grains or stored 703 texture results. 704 705 """ 706 n_grains = minerals[0].n_grains 707 if not np.all([m.n_grains == n_grains for m in minerals[1:]]): 708 raise ValueError("cannot average minerals with unequal grain counts") 709 n_steps = len(minerals[0].orientations) 710 if not np.all([len(m.orientations) == n_steps for m in minerals[1:]]): 711 raise ValueError( 712 "cannot average minerals with variable-length orientation arrays" 713 ) 714 if not np.all([len(m.fractions) == n_steps for m in minerals]): 715 raise ValueError( 716 "cannot average minerals with variable-length grain volume arrays" 717 ) 718 719 # TODO: Perform rotation directly on the 6x6 matrices, see Carcione 2007. 720 # This trick is implemented in cpo_elastic_tensor.cc in Aspect. 721 average_tensors = np.zeros((n_steps, 6, 6)) 722 for i in range(n_steps): 723 for mineral in minerals: 724 for n in range(n_grains): 725 match mineral.phase: 726 case _core.MineralPhase.olivine: 727 average_tensors[i] += _tensors.elastic_tensor_to_voigt( 728 _tensors.rotate( 729 elastic_tensors.olivine, 730 mineral.orientations[i][n, ...].transpose(), 731 ) 732 * mineral.fractions[i][n] 733 * phase_fractions[phase_assemblage.index(mineral.phase)] 734 ) 735 case _core.MineralPhase.enstatite: 736 average_tensors[i] += _tensors.elastic_tensor_to_voigt( 737 _tensors.rotate( 738 elastic_tensors.enstatite, 739 mineral.orientations[i][n, ...].transpose(), 740 ) 741 * mineral.fractions[i][n] 742 * phase_fractions[phase_assemblage.index(mineral.phase)] 743 ) 744 case _: 745 raise ValueError(f"unsupported mineral phase: {mineral.phase}") 746 return average_tensors
Primary slip axis name for for the given olivine fabric
.
Slip systems for olivine in conventional order.
Tuples contain the slip plane normal and slip direction vectors.
The order of slip systems returned matches the order of critical shear stresses
returned by pydrex.core.get_crss
.
53@dataclass 54class StiffnessTensors: 55 olivine: np.ndarray = field( 56 default_factory=lambda: np.array( 57 [ 58 [320.71, 69.84, 71.22, 0.0, 0.0, 0.0], 59 [69.84, 197.25, 74.8, 0.0, 0.0, 0.0], 60 [71.22, 74.8, 234.32, 0.0, 0.0, 0.0], 61 [0.0, 0.0, 0.0, 63.77, 0.0, 0.0], 62 [0.0, 0.0, 0.0, 0.0, 77.67, 0.0], 63 [0.0, 0.0, 0.0, 0.0, 0.0, 78.36], 64 ] 65 ) 66 ) 67 """Stiffness tensor for olivine (Voigt representation), with units of GPa. 68 69 The source of the values used here is unknown, but they are copied 70 from the original DRex code: <http://www.ipgp.fr/~kaminski/web_doudoud/DRex.tar.gz> 71 [88K download] 72 73 """ 74 enstatite: np.ndarray = field( 75 default_factory=lambda: np.array( 76 [ 77 [236.9, 79.6, 63.2, 0.0, 0.0, 0.0], 78 [79.6, 180.5, 56.8, 0.0, 0.0, 0.0], 79 [63.2, 56.8, 230.4, 0.0, 0.0, 0.0], 80 [0.0, 0.0, 0.0, 84.3, 0.0, 0.0], 81 [0.0, 0.0, 0.0, 0.0, 79.4, 0.0], 82 [0.0, 0.0, 0.0, 0.0, 0.0, 80.1], 83 ] 84 ) 85 ) 86 """Stiffness tensor for enstatite (Voigt representation), with units of GPa. 87 88 The source of the values used here is unknown, but they are copied 89 from the original DRex code: <http://www.ipgp.fr/~kaminski/web_doudoud/DRex.tar.gz> 90 [88K download] 91 92 """ 93 94 def __iter__(self): 95 # So that [S for S in StiffnessTensors()] can be indexed with `MineralPhase`s. 96 indexed = { 97 _core.MineralPhase.olivine: self.olivine, 98 _core.MineralPhase.enstatite: self.enstatite, 99 } 100 for _, v in sorted(indexed.items()): 101 yield v
Stiffness tensor for olivine (Voigt representation), with units of GPa.
The source of the values used here is unknown, but they are copied from the original DRex code: http://www.ipgp.fr/~kaminski/web_doudoud/DRex.tar.gz [88K download]
Stiffness tensor for enstatite (Voigt representation), with units of GPa.
The source of the values used here is unknown, but they are copied from the original DRex code: http://www.ipgp.fr/~kaminski/web_doudoud/DRex.tar.gz [88K download]
104def peridotite_solidus(pressure, fit="Hirschmann2000"): 105 """Get peridotite solidus (i.e. melting) temperature based on experimental fits. 106 107 Pressure is expected to be in GPa. 108 109 Supported fits: 110 - ["Hirschmann2000"](https://doi.org/10.1029/2000GC000070) 111 - ["Herzberg2000"](https://doi.org/10.1029/2000GC000089) 112 - ["Duvernay2024"](https://doi.org/10.1029/2023GC011288) 113 114 """ 115 match fit: 116 case "Herzberg2000": 117 return 1086 - 5.7 * pressure + 390 * np.log(pressure) 118 case "Hirschmann2000": 119 return -5.104 * pressure**2 + 132.899 * pressure + 1120.661 120 case "Duvernay2024": 121 return -6.8 * pressure**2 + 141.4 * pressure + 1101.3 122 case _: 123 raise ValueError(f"unsupported fit '{fit}'")
Get peridotite solidus (i.e. melting) temperature based on experimental fits.
Pressure is expected to be in GPa.
Supported fits:
126@dataclass 127class Mineral: 128 """Class for storing polycrystal texture for a single mineral phase. 129 130 A `Mineral` stores texture data for an aggregate of grains¹. 131 Additionally, mineral fabric type and deformation regime are also tracked. 132 To provide an initial texture for the mineral, use the constructor arguments 133 `fractions_init` and `orientations_init`. By default, 134 a uniform volume distribution of random orientations is generated. 135 136 The `update_orientations` method computes new orientations and grain volumes 137 for a given velocity gradient. These results are stored in the `.orientations` and 138 `.fractions` attributes of the `Mineral` instance. The method also returns the 139 updated macroscopic deformation gradient based on the provided initial deformation 140 gradient. 141 142 ¹Note that the "number of grains" is a static integer value that 143 does not track the actual number of physical grains in the deforming polycrystal. 144 Instead, this number acts as a "number of bins" for the statistical resolution of 145 the crystallographic orientation distribution. The value is roughly equivalent to 146 (a multiple of) the number of initial, un-recrystallised grains in the polycrystal. 147 It is assumed that recrystallised grains do not grow large enough to require 148 rotation tracking. 149 150 **Examples:** 151 152 Mineral with isotropic initial texture: 153 154 >>> import pydrex 155 >>> olA = pydrex.Mineral( 156 ... phase=pydrex.MineralPhase.olivine, 157 ... fabric=pydrex.MineralFabric.olivine_A, 158 ... regime=pydrex.DeformationRegime.matrix_dislocation, 159 ... n_grains=2000 160 ... ) 161 >>> olA.phase 162 <MineralPhase.olivine: 0> 163 >>> olA.fabric 164 <MineralFabric.olivine_A: 0> 165 >>> olA.regime 166 <DeformationRegime.matrix_dislocation: 4> 167 >>> olA.n_grains 168 2000 169 170 Mineral with specified initial texture and default phase, fabric and regime settings 171 which are for an olivine A-type mineral in the dislocation creep regime. 172 The initial grain volume fractions should be normalised. 173 174 >>> import numpy as np 175 >>> from scipy.spatial.transform import Rotation 176 >>> import pydrex 177 >>> rng = np.random.default_rng() 178 >>> n_grains = 2000 179 >>> olA = pydrex.Mineral( 180 ... n_grains=n_grains, 181 ... fractions_init=np.full(n_grains, 1 / n_grains), 182 ... orientations_init=Rotation.from_euler( 183 ... "zxz", [ 184 ... [x * np.pi / 2, np.pi / 2, np.pi / 2] for x in rng.random(n_grains) 185 ... ] 186 ... ).inv().as_matrix(), 187 ... ) 188 >>> len(olA.orientations) 189 1 190 >>> type(olA.orientations) 191 <class 'list'> 192 >>> olA.orientations[0].shape 193 (2000, 3, 3) 194 >>> olA.fractions[0].shape 195 (2000,) 196 197 Note that minerals can also be constructed from serialized data, 198 see `Mineral.load` and `Mineral.from_file`. 199 200 **Attributes:** 201 - `phase` (`pydrex.core.MineralPhase`) — ordinal number of the mineral phase 202 - `fabric` (`pydrex.core.MineralFabric`) — ordinal number of the fabric type 203 - `regime` (`pydrex.core.DeformationRegime`) — ordinal number of the deformation 204 regime 205 - `n_grains` (int) — number of grains in the aggregate 206 - `fractions` (list of arrays) — grain volume fractions for each texture snapshot 207 - `orientations` (list of arrays) — grain orientation matrices for each texture 208 snapshot 209 - `seed` (int) — seed used by the random number generator to set up the isotropic 210 initial condition when `fractions_init` or `orientations_init` are not provided 211 - `lband` (int) — passed to the `scipy.integrate.LSODA` solver 212 - `uband` (int) — passed to the `scipy.integrate.LSODA` solver 213 214 """ 215 216 phase: int = _core.MineralPhase.olivine 217 fabric: int = _core.MineralFabric.olivine_A 218 regime: int = _core.DeformationRegime.matrix_dislocation 219 n_grains: int = _core.DefaultParams().number_of_grains 220 # Initial condition, randomised if not given. 221 fractions_init: np.ndarray | None = None 222 orientations_init: np.ndarray | None = None 223 fractions: list = field(default_factory=list) 224 orientations: list = field(default_factory=list) 225 seed: int | None = None 226 lband: int | None = None 227 uband: int | None = None 228 229 def __str__(self): 230 # String output, used for str(self) and f"{self}", etc. 231 if hasattr(self.fractions[0], "shape"): 232 shape_of_fractions = str(self.fractions[0].shape) 233 else: 234 shape_of_fractions = "(?)" 235 236 if hasattr(self.orientations[0], "shape"): 237 shape_of_orientations = str(self.orientations[0].shape) 238 else: 239 shape_of_orientations = "(?)" 240 241 obj = self.__class__.__qualname__ 242 phase = f"(phase={self.phase!r}, " 243 fabric = f"fabric={self.fabric!r}, " 244 regime = f"regime={self.regime!r}, " 245 n_grains = f"n_grains={self.n_grains}, " 246 _fclass = self.fractions.__class__.__qualname__ 247 _f0class = self.fractions[0].__class__.__qualname__ 248 frac = f"fractions=<{_fclass} of {_f0class} {shape_of_fractions}>, " 249 _oclass = self.orientations.__class__.__qualname__ 250 _o0class = self.orientations[0].__class__.__qualname__ 251 orient = f"orientations=<{_oclass} of {_o0class} {shape_of_orientations}>)" 252 return f"{obj}{phase}{fabric}{regime}{n_grains}{frac}{orient}" 253 254 def _repr_pretty_(self, p, cycle): 255 # Format to use when printing to IPython or other interactive console. 256 p.text(self.__str__() if not cycle else self.__class__.__qualname__ + "(...)") 257 258 def __post_init__(self): 259 """Initialise random orientations and grain volume fractions.""" 260 if self.fractions_init is None: 261 self.fractions_init = np.full(self.n_grains, 1.0 / self.n_grains) 262 if self.orientations_init is None: 263 self.orientations_init = Rotation.random( 264 self.n_grains, random_state=self.seed 265 ).as_matrix() 266 # For large numbers of grains, the number of ODE's exceeds what LSODA can 267 # handle. Therefore, we specify the Jacobian matrix as banded. 268 # By default, we use a bandwidth o f 12000 with lband = uband = 6000. 269 # This should work for up to 10000 grains. 270 if self.lband is None and self.uband is None and self.n_grains > 4632: 271 _log.warning( 272 "using a banded Jacobian because of the large number of grains." 273 + " To manually control the bandwidth, set `lband` and/or `uband`" 274 + f" in calls to `{self.__class__.__qualname__}.update_orientations`." 275 ) 276 self.lband = 6000 277 self.uband = 6000 278 279 # Copy the initial values to the storage lists. 280 self.fractions.append(self.fractions_init) 281 self.orientations.append(self.orientations_init) 282 283 # Delete the initial value duplicates to avoid confusion. 284 del self.fractions_init 285 del self.orientations_init 286 287 _log.info("created %s", self) 288 289 def __eq__(self, other): 290 if other.__class__ is self.__class__: 291 return ( 292 self.phase == other.phase 293 and self.fabric == other.fabric 294 and self.regime == other.regime 295 and self.n_grains == other.n_grains 296 and len(self.fractions) == len(other.fractions) 297 and np.all( 298 Ŋ([f.shape for f in self.fractions]) 299 == Ŋ([f.shape for f in other.fractions]) 300 ) 301 and np.all(Ŋ(self.fractions) == Ŋ(other.fractions)) 302 and len(self.orientations) == len(other.orientations) 303 and np.all( 304 Ŋ([f.shape for f in self.orientations]) 305 == Ŋ([f.shape for f in other.orientations]) 306 ) 307 and np.all(Ŋ(self.orientations) == Ŋ(other.orientations)) 308 and self.seed == other.seed 309 and self.lband == other.lband 310 and self.uband == other.uband 311 ) 312 return False 313 314 def update_orientations( 315 self, 316 params: dict, 317 deformation_gradient: np.ndarray, 318 get_velocity_gradient, 319 pathline: tuple, 320 get_regime=None, 321 **kwargs, 322 ) -> np.ndarray: 323 """Update orientations and volume distribution for the `Mineral`. 324 325 Update crystalline orientations and grain volume distribution 326 for minerals undergoing plastic deformation. Return the updated deformation 327 gradient measuring the corresponding macroscopic deformation. 328 329 .. note:: For multi-phase simulations, the return value should only be collected 330 for the last phase, i.e. the last call to `update_orientations`. This value 331 should be passed on to subsequent calls to `update_orientations` as the 332 `deformation_gradient` argument. 333 334 - `params` — dictionary with the same keys as the return value of 335 `pydrex.core.DefaultParams.as_dict()` 336 - `deformation_gradient` — 3x3 deformation gradient tensor 337 - `get_velocity_gradient` — callable with signature `f(t, x)` that returns a 3x3 338 velocity gradient matrix at time `t` and position `x` (3D vector) 339 - `pathline` — tuple consisting of: 340 1. the time at which to start the CPO integration 341 2. the time at which to stop the CPO integration 342 3. a callable with signature `f(t)` that returns the position of the mineral 343 at time `t` where $t ∈ [t_{start}, t_{end}]$ 344 - `get_regime` (optional) — callable with signature f(t, x) that returns a 345 `pydrex.core.DeformationRegime` declaring the dominant deformation mechanism 346 at time `t` and position `x` (3D vector) 347 348 Any additional (optional) keyword arguments are passed to 349 `scipy.integrate.LSODA`. 350 351 Array values must provide a NumPy-compatible interface: 352 <https://numpy.org/doc/stable/user/whatisnumpy.html> 353 354 Examples: 355 356 >>> from itertools import pairwise 357 >>> import pydrex 358 >>> olA = pydrex.Mineral( 359 ... phase=pydrex.MineralPhase.olivine, 360 ... fabric=pydrex.MineralFabric.olivine_A, 361 ... regime=pydrex.DeformationRegime.matrix_dislocation, 362 ... n_grains=pydrex.DefaultParams().number_of_grains, 363 ... ) 364 >>> def get_velocity_gradient(t, x): # Simple L for simple shear. 365 ... L = np.zeros((3, 3)) 366 ... L[0, 2] = 1 367 ... return L 368 >>> def get_position(t): 369 ... return np.zeros(3) # Stationary polycrystal for this example. 370 >>> timestamps = range(2) # Just 1 timestep for demonstration. 371 >>> deformation_gradient = np.eye(3) # Start with an undeformed polycrystal. 372 >>> for t_start, t_end in pairwise(timestamps): 373 ... # Update deformation_gradient, olA.orientations and olA.fractions. 374 ... deformation_gradient = olA.update_orientations( 375 ... pydrex.DefaultParams().as_dict(), 376 ... deformation_gradient, 377 ... get_velocity_gradient, 378 ... (t_start, t_end, get_position), 379 ... ) 380 >>> from numpy import testing as nt 381 >>> nt.assert_allclose(deformation_gradient, 382 ... np.eye(3) + get_velocity_gradient(t_end, np.nan), 383 ... atol=1e-15, rtol=0 384 ... ) 385 386 """ 387 388 # ===== Set up callables for the ODE solver and internal processing ===== 389 390 def eval_rhs(t, y): 391 """Evaluate right hand side of the D-Rex PDE.""" 392 # assert not np.any(np.isnan(y)), y[np.isnan(y)].shape 393 position = get_position(t) 394 velocity_gradient = get_velocity_gradient(t, position) 395 # _log.debug( 396 # "calculating CPO at %s (t=%e) with velocity gradient %s", 397 # position, 398 # t, 399 # velocity_gradient.ravel(), 400 # ) 401 402 if self.phase not in params["phase_assemblage"]: 403 # Warning rather than failure, so that we tolerate loops like: 404 # [m.update_orientations(...) for m in minerals] 405 # Where some minerals in the list might be (temporarily) superfluous. 406 _log.warning( 407 "skipping %s mineral, phase omitted from configuration", self.phase 408 ) 409 return 410 411 if get_regime is not None: 412 self.regime = get_regime(t, position) 413 414 try: 415 volume_fraction = params["phase_fractions"][ 416 params["phase_assemblage"].index(self.phase) 417 ] 418 except IndexError: 419 raise ValueError( 420 f"phase must be a valid `MineralPhase`, not {self.phase}" 421 ) 422 423 strain_rate = (velocity_gradient + velocity_gradient.transpose()) / 2 424 strain_rate_max = np.abs(la.eigvalsh(strain_rate)).max() 425 deformation_gradient, orientations, fractions = _utils.extract_vars( 426 y, self.n_grains 427 ) 428 deformation_gradient_diff = velocity_gradient @ deformation_gradient 429 deformation_gradient_spin = _tensors.polar_decompose( 430 deformation_gradient_diff 431 )[1] 432 # Uses nondimensional values of strain rate and velocity gradient. 433 orientations_diff, fractions_diff = _core.derivatives( 434 regime=self.regime, 435 phase=self.phase, 436 fabric=self.fabric, 437 n_grains=self.n_grains, 438 orientations=orientations, 439 fractions=fractions, 440 strain_rate=strain_rate / strain_rate_max, 441 velocity_gradient=velocity_gradient / strain_rate_max, 442 deformation_gradient_spin=deformation_gradient_spin, 443 stress_exponent=params["stress_exponent"], 444 deformation_exponent=params["deformation_exponent"], 445 nucleation_efficiency=params["nucleation_efficiency"], 446 gbm_mobility=params["gbm_mobility"], 447 volume_fraction=volume_fraction, 448 ) 449 return np.hstack( 450 ( 451 deformation_gradient_diff.flatten(), 452 orientations_diff.flatten() * strain_rate_max, 453 fractions_diff * strain_rate_max, 454 ) 455 ) 456 457 def perform_step(solver): 458 """Perform SciPy solver step and appropriate processing.""" 459 message = solver.step() 460 if message is not None and solver.status == "failed": 461 raise _err.IterationError(message) 462 # _log.debug( 463 # "%s step_size=%e", solver.__class__.__qualname__, solver.step_size 464 # ) 465 466 deformation_gradient, orientations, fractions = _utils.extract_vars( 467 solver.y, self.n_grains 468 ) 469 orientations, fractions = _utils.apply_gbs( 470 orientations, 471 fractions, 472 params["gbs_threshold"], 473 self.orientations[-1], 474 self.n_grains, 475 ) 476 solver.y[9:] = np.hstack((orientations.flatten(), fractions)) 477 478 # ===== Initialise and run the solver using the above callables ===== 479 480 time_start, time_end, get_position = pathline 481 if not callable(get_velocity_gradient): 482 raise ValueError( 483 "unable to evaluate velocity gradient callable." 484 + " You must provide a callable with signature f(t, x)" 485 + " that returns a 3x3 matrix." 486 ) 487 if not callable(get_position): 488 raise ValueError( 489 "unable to evaluate position callable." 490 + " You must provide a callable with signature f(t)" 491 + " that returns a 3-component array." 492 ) 493 _log.debug( 494 "calculating CPO from %s (t=%s) to %s (t=%s)", 495 get_position(time_start), 496 time_start, 497 get_position(time_end), 498 time_end, 499 ) 500 _log.debug(" with deformation gradient %s", deformation_gradient.ravel()) 501 _log.debug( 502 " with velocity gradient interpolated between %s and %s", 503 get_velocity_gradient(time_start, get_position(time_start)).ravel(), 504 get_velocity_gradient(time_end, get_position(time_end)).ravel(), 505 ) 506 _log.debug( 507 " intermediate velocity gradient = %s", 508 get_velocity_gradient( 509 (time_start + time_end) / 2, get_position((time_start + time_end) / 2) 510 ).ravel(), 511 ) 512 513 y_start = np.hstack( 514 ( 515 deformation_gradient.flatten(), 516 self.orientations[-1].flatten(), 517 self.fractions[-1], 518 ) 519 ) 520 solver = LSODA( 521 eval_rhs, 522 time_start, 523 y_start, 524 time_end, 525 atol=kwargs.pop("atol", np.abs(y_start * 1e-6) + 1e-12), 526 rtol=kwargs.pop("rtol", 1e-6), 527 first_step=kwargs.pop("first_step", np.abs(time_end - time_start) * 1e-1), 528 # max_step=kwargs.pop("max_step", np.abs(time_end - time_start)), 529 lband=self.lband, 530 uband=self.uband, 531 **kwargs, 532 ) 533 perform_step(solver) 534 while solver.status == "running": 535 perform_step(solver) 536 537 # Extract final values for this simulation step, append to storage. 538 deformation_gradient, orientations, fractions = _utils.extract_vars( 539 solver.y.squeeze(), self.n_grains 540 ) 541 self.orientations.append(orientations) 542 self.fractions.append(fractions) 543 return deformation_gradient 544 545 def save(self, filename, postfix=None): 546 """Save CPO data for all stored timesteps to a `numpy` NPZ file. 547 548 If `postfix` is not `None`, the data is appended to the NPZ file 549 in fields ending with "`_postfix`". 550 551 Raises a `ValueError` if the data shapes are not compatible. 552 553 See also: `numpy.savez`, `Mineral.load`, `Mineral.from_file`. 554 555 """ 556 if len(self.fractions) != len(self.orientations): 557 raise ValueError( 558 "Length of stored results must match." 559 + " You've supplied currupted data with:\n" 560 + f"- {len(self.fractions)} grain size results, and\n" 561 + f"- {len(self.orientations)} orientation results." 562 ) 563 if self.fractions[0].shape[0] == self.orientations[0].shape[0] == self.n_grains: 564 data = { 565 "meta": np.array( 566 [self.phase, self.fabric, self.regime], dtype=np.uint8 567 ), 568 "fractions": np.stack(self.fractions), 569 "orientations": np.stack(self.orientations), 570 } 571 # Create parent directories, resolve relative paths. 572 _io.resolve_path(filename) 573 # Append to file, requires postfix (unique name). 574 if postfix is not None: 575 _log.info("saving Mineral to file %s (postfix: %s)", filename, postfix) 576 archive = ZipFile(filename, mode="a", allowZip64=True) 577 for key in data.keys(): 578 with archive.open( 579 f"{key}_{postfix}", "w", force_zip64=True 580 ) as file: 581 buffer = io.BytesIO() 582 np.save(buffer, data[key]) 583 file.write(buffer.getvalue()) 584 buffer.close() 585 else: 586 _log.info("saving Mineral to file %s", filename) 587 np.savez(filename, **data) 588 else: 589 raise ValueError( 590 "Size of CPO data arrays must match number of grains." 591 + " You've supplied corrupted data with:\n" 592 + f"- `n_grains = {self.n_grains}`,\n" 593 + f"- `fractions[0].shape = {self.fractions[0].shape}`, and\n" 594 + f"- `orientations[0].shape = {self.orientations[0].shape}`." 595 ) 596 597 def load(self, filename, postfix=None): 598 """Load CPO data from a `numpy` NPZ file. 599 600 If `postfix` is not `None`, data is read from fields ending with "`_postfix`". 601 602 See also: `Mineral.save`, `Mineral.from_file`. 603 604 """ 605 if not filename.endswith(".npz"): 606 raise ValueError( 607 f"Must only load from numpy NPZ format. Cannot load from {filename}." 608 ) 609 data = np.load(filename) 610 if postfix is not None: 611 phase, fabric, regime = data[f"meta_{postfix}"] 612 self.fractions = list(data[f"fractions_{postfix}"]) 613 self.orientations = list(data[f"orientations_{postfix}"]) 614 else: 615 phase, fabric, regime = data["meta"] 616 self.fractions = list(data["fractions"]) 617 self.orientations = list(data["orientations"]) 618 619 self.phase = phase 620 self.fabric = fabric 621 self.regime = regime 622 self.orientations_init = self.orientations[0] 623 self.fractions_init = self.fractions[0] 624 625 @classmethod 626 def from_file(cls, filename, postfix=None): 627 """Construct a `Mineral` instance using data from a `numpy` NPZ file. 628 629 If `postfix` is not `None`, data is read from fields ending with “`_postfix`”. 630 631 See also: `Mineral.save`, `Mineral.load`. 632 633 """ 634 if not filename.endswith(".npz"): 635 raise ValueError( 636 f"Must only load from numpy NPZ format. Cannot load from {filename}." 637 ) 638 data = np.load(filename) 639 if postfix is not None: 640 phase, fabric, regime = data[f"meta_{postfix}"] 641 fractions = list(data[f"fractions_{postfix}"]) 642 orientations = list(data[f"orientations_{postfix}"]) 643 else: 644 phase, fabric, regime = data["meta"] 645 fractions = list(data["fractions"]) 646 orientations = list(data["orientations"]) 647 648 mineral = cls( 649 phase, 650 fabric, 651 regime, 652 n_grains=len(fractions[0]), 653 fractions_init=fractions[0], 654 orientations_init=orientations[0], 655 ) 656 mineral.fractions = fractions 657 mineral.orientations = orientations 658 return mineral
Class for storing polycrystal texture for a single mineral phase.
A Mineral
stores texture data for an aggregate of grains¹.
Additionally, mineral fabric type and deformation regime are also tracked.
To provide an initial texture for the mineral, use the constructor arguments
fractions_init
and orientations_init
. By default,
a uniform volume distribution of random orientations is generated.
The update_orientations
method computes new orientations and grain volumes
for a given velocity gradient. These results are stored in the .orientations
and
.fractions
attributes of the Mineral
instance. The method also returns the
updated macroscopic deformation gradient based on the provided initial deformation
gradient.
¹Note that the "number of grains" is a static integer value that does not track the actual number of physical grains in the deforming polycrystal. Instead, this number acts as a "number of bins" for the statistical resolution of the crystallographic orientation distribution. The value is roughly equivalent to (a multiple of) the number of initial, un-recrystallised grains in the polycrystal. It is assumed that recrystallised grains do not grow large enough to require rotation tracking.
Examples:
Mineral with isotropic initial texture:
>>> import pydrex
>>> olA = Mineral(
... phase=pydrex.MineralPhase.olivine,
... fabric=pydrex.MineralFabric.olivine_A,
... regime=pydrex.DeformationRegime.matrix_dislocation,
... n_grains=2000
... )
>>> olA.phase
<MineralPhase.olivine: 0>
>>> olA.fabric
<MineralFabric.olivine_A: 0>
>>> olA.regime
<DeformationRegime.matrix_dislocation: 4>
>>> olA.n_grains
2000
Mineral with specified initial texture and default phase, fabric and regime settings which are for an olivine A-type mineral in the dislocation creep regime. The initial grain volume fractions should be normalised.
>>> import numpy as np
>>> from scipy.spatial.transform import Rotation
>>> import pydrex
>>> rng = np.random.default_rng()
>>> n_grains = 2000
>>> olA = Mineral(
... n_grains=n_grains,
... fractions_init=np.full(n_grains, 1 / n_grains),
... orientations_init=Rotation.from_euler(
... "zxz", [
... [x * np.pi / 2, np.pi / 2, np.pi / 2] for x in rng.random(n_grains)
... ]
... ).inv().as_matrix(),
... )
>>> len(olA.orientations)
1
>>> type(olA.orientations)
<class 'list'>
>>> olA.orientations[0].shape
(2000, 3, 3)
>>> olA.fractions[0].shape
(2000,)
Note that minerals can also be constructed from serialized data,
see Mineral.load
and Mineral.from_file
.
Attributes:
phase
(pydrex.core.MineralPhase
) — ordinal number of the mineral phasefabric
(pydrex.core.MineralFabric
) — ordinal number of the fabric typeregime
(pydrex.core.DeformationRegime
) — ordinal number of the deformation regimen_grains
(int) — number of grains in the aggregatefractions
(list of arrays) — grain volume fractions for each texture snapshotorientations
(list of arrays) — grain orientation matrices for each texture snapshotseed
(int) — seed used by the random number generator to set up the isotropic initial condition whenfractions_init
ororientations_init
are not providedlband
(int) — passed to thescipy.integrate.LSODA
solveruband
(int) — passed to thescipy.integrate.LSODA
solver
314 def update_orientations( 315 self, 316 params: dict, 317 deformation_gradient: np.ndarray, 318 get_velocity_gradient, 319 pathline: tuple, 320 get_regime=None, 321 **kwargs, 322 ) -> np.ndarray: 323 """Update orientations and volume distribution for the `Mineral`. 324 325 Update crystalline orientations and grain volume distribution 326 for minerals undergoing plastic deformation. Return the updated deformation 327 gradient measuring the corresponding macroscopic deformation. 328 329 .. note:: For multi-phase simulations, the return value should only be collected 330 for the last phase, i.e. the last call to `update_orientations`. This value 331 should be passed on to subsequent calls to `update_orientations` as the 332 `deformation_gradient` argument. 333 334 - `params` — dictionary with the same keys as the return value of 335 `pydrex.core.DefaultParams.as_dict()` 336 - `deformation_gradient` — 3x3 deformation gradient tensor 337 - `get_velocity_gradient` — callable with signature `f(t, x)` that returns a 3x3 338 velocity gradient matrix at time `t` and position `x` (3D vector) 339 - `pathline` — tuple consisting of: 340 1. the time at which to start the CPO integration 341 2. the time at which to stop the CPO integration 342 3. a callable with signature `f(t)` that returns the position of the mineral 343 at time `t` where $t ∈ [t_{start}, t_{end}]$ 344 - `get_regime` (optional) — callable with signature f(t, x) that returns a 345 `pydrex.core.DeformationRegime` declaring the dominant deformation mechanism 346 at time `t` and position `x` (3D vector) 347 348 Any additional (optional) keyword arguments are passed to 349 `scipy.integrate.LSODA`. 350 351 Array values must provide a NumPy-compatible interface: 352 <https://numpy.org/doc/stable/user/whatisnumpy.html> 353 354 Examples: 355 356 >>> from itertools import pairwise 357 >>> import pydrex 358 >>> olA = pydrex.Mineral( 359 ... phase=pydrex.MineralPhase.olivine, 360 ... fabric=pydrex.MineralFabric.olivine_A, 361 ... regime=pydrex.DeformationRegime.matrix_dislocation, 362 ... n_grains=pydrex.DefaultParams().number_of_grains, 363 ... ) 364 >>> def get_velocity_gradient(t, x): # Simple L for simple shear. 365 ... L = np.zeros((3, 3)) 366 ... L[0, 2] = 1 367 ... return L 368 >>> def get_position(t): 369 ... return np.zeros(3) # Stationary polycrystal for this example. 370 >>> timestamps = range(2) # Just 1 timestep for demonstration. 371 >>> deformation_gradient = np.eye(3) # Start with an undeformed polycrystal. 372 >>> for t_start, t_end in pairwise(timestamps): 373 ... # Update deformation_gradient, olA.orientations and olA.fractions. 374 ... deformation_gradient = olA.update_orientations( 375 ... pydrex.DefaultParams().as_dict(), 376 ... deformation_gradient, 377 ... get_velocity_gradient, 378 ... (t_start, t_end, get_position), 379 ... ) 380 >>> from numpy import testing as nt 381 >>> nt.assert_allclose(deformation_gradient, 382 ... np.eye(3) + get_velocity_gradient(t_end, np.nan), 383 ... atol=1e-15, rtol=0 384 ... ) 385 386 """ 387 388 # ===== Set up callables for the ODE solver and internal processing ===== 389 390 def eval_rhs(t, y): 391 """Evaluate right hand side of the D-Rex PDE.""" 392 # assert not np.any(np.isnan(y)), y[np.isnan(y)].shape 393 position = get_position(t) 394 velocity_gradient = get_velocity_gradient(t, position) 395 # _log.debug( 396 # "calculating CPO at %s (t=%e) with velocity gradient %s", 397 # position, 398 # t, 399 # velocity_gradient.ravel(), 400 # ) 401 402 if self.phase not in params["phase_assemblage"]: 403 # Warning rather than failure, so that we tolerate loops like: 404 # [m.update_orientations(...) for m in minerals] 405 # Where some minerals in the list might be (temporarily) superfluous. 406 _log.warning( 407 "skipping %s mineral, phase omitted from configuration", self.phase 408 ) 409 return 410 411 if get_regime is not None: 412 self.regime = get_regime(t, position) 413 414 try: 415 volume_fraction = params["phase_fractions"][ 416 params["phase_assemblage"].index(self.phase) 417 ] 418 except IndexError: 419 raise ValueError( 420 f"phase must be a valid `MineralPhase`, not {self.phase}" 421 ) 422 423 strain_rate = (velocity_gradient + velocity_gradient.transpose()) / 2 424 strain_rate_max = np.abs(la.eigvalsh(strain_rate)).max() 425 deformation_gradient, orientations, fractions = _utils.extract_vars( 426 y, self.n_grains 427 ) 428 deformation_gradient_diff = velocity_gradient @ deformation_gradient 429 deformation_gradient_spin = _tensors.polar_decompose( 430 deformation_gradient_diff 431 )[1] 432 # Uses nondimensional values of strain rate and velocity gradient. 433 orientations_diff, fractions_diff = _core.derivatives( 434 regime=self.regime, 435 phase=self.phase, 436 fabric=self.fabric, 437 n_grains=self.n_grains, 438 orientations=orientations, 439 fractions=fractions, 440 strain_rate=strain_rate / strain_rate_max, 441 velocity_gradient=velocity_gradient / strain_rate_max, 442 deformation_gradient_spin=deformation_gradient_spin, 443 stress_exponent=params["stress_exponent"], 444 deformation_exponent=params["deformation_exponent"], 445 nucleation_efficiency=params["nucleation_efficiency"], 446 gbm_mobility=params["gbm_mobility"], 447 volume_fraction=volume_fraction, 448 ) 449 return np.hstack( 450 ( 451 deformation_gradient_diff.flatten(), 452 orientations_diff.flatten() * strain_rate_max, 453 fractions_diff * strain_rate_max, 454 ) 455 ) 456 457 def perform_step(solver): 458 """Perform SciPy solver step and appropriate processing.""" 459 message = solver.step() 460 if message is not None and solver.status == "failed": 461 raise _err.IterationError(message) 462 # _log.debug( 463 # "%s step_size=%e", solver.__class__.__qualname__, solver.step_size 464 # ) 465 466 deformation_gradient, orientations, fractions = _utils.extract_vars( 467 solver.y, self.n_grains 468 ) 469 orientations, fractions = _utils.apply_gbs( 470 orientations, 471 fractions, 472 params["gbs_threshold"], 473 self.orientations[-1], 474 self.n_grains, 475 ) 476 solver.y[9:] = np.hstack((orientations.flatten(), fractions)) 477 478 # ===== Initialise and run the solver using the above callables ===== 479 480 time_start, time_end, get_position = pathline 481 if not callable(get_velocity_gradient): 482 raise ValueError( 483 "unable to evaluate velocity gradient callable." 484 + " You must provide a callable with signature f(t, x)" 485 + " that returns a 3x3 matrix." 486 ) 487 if not callable(get_position): 488 raise ValueError( 489 "unable to evaluate position callable." 490 + " You must provide a callable with signature f(t)" 491 + " that returns a 3-component array." 492 ) 493 _log.debug( 494 "calculating CPO from %s (t=%s) to %s (t=%s)", 495 get_position(time_start), 496 time_start, 497 get_position(time_end), 498 time_end, 499 ) 500 _log.debug(" with deformation gradient %s", deformation_gradient.ravel()) 501 _log.debug( 502 " with velocity gradient interpolated between %s and %s", 503 get_velocity_gradient(time_start, get_position(time_start)).ravel(), 504 get_velocity_gradient(time_end, get_position(time_end)).ravel(), 505 ) 506 _log.debug( 507 " intermediate velocity gradient = %s", 508 get_velocity_gradient( 509 (time_start + time_end) / 2, get_position((time_start + time_end) / 2) 510 ).ravel(), 511 ) 512 513 y_start = np.hstack( 514 ( 515 deformation_gradient.flatten(), 516 self.orientations[-1].flatten(), 517 self.fractions[-1], 518 ) 519 ) 520 solver = LSODA( 521 eval_rhs, 522 time_start, 523 y_start, 524 time_end, 525 atol=kwargs.pop("atol", np.abs(y_start * 1e-6) + 1e-12), 526 rtol=kwargs.pop("rtol", 1e-6), 527 first_step=kwargs.pop("first_step", np.abs(time_end - time_start) * 1e-1), 528 # max_step=kwargs.pop("max_step", np.abs(time_end - time_start)), 529 lband=self.lband, 530 uband=self.uband, 531 **kwargs, 532 ) 533 perform_step(solver) 534 while solver.status == "running": 535 perform_step(solver) 536 537 # Extract final values for this simulation step, append to storage. 538 deformation_gradient, orientations, fractions = _utils.extract_vars( 539 solver.y.squeeze(), self.n_grains 540 ) 541 self.orientations.append(orientations) 542 self.fractions.append(fractions) 543 return deformation_gradient
Update orientations and volume distribution for the Mineral
.
Update crystalline orientations and grain volume distribution for minerals undergoing plastic deformation. Return the updated deformation gradient measuring the corresponding macroscopic deformation.
For multi-phase simulations, the return value should only be collected
for the last phase, i.e. the last call to update_orientations
. This value
should be passed on to subsequent calls to update_orientations
as the
deformation_gradient
argument.
params
— dictionary with the same keys as the return value ofpydrex.core.DefaultParams.as_dict()
deformation_gradient
— 3x3 deformation gradient tensorget_velocity_gradient
— callable with signaturef(t, x)
that returns a 3x3 velocity gradient matrix at timet
and positionx
(3D vector)pathline
— tuple consisting of:- the time at which to start the CPO integration
- the time at which to stop the CPO integration
- a callable with signature
f(t)
that returns the position of the mineral at timet
where $t ∈ [t_{start}, t_{end}]$
get_regime
(optional) — callable with signature f(t, x) that returns apydrex.core.DeformationRegime
declaring the dominant deformation mechanism at timet
and positionx
(3D vector)
Any additional (optional) keyword arguments are passed to
scipy.integrate.LSODA
.
Array values must provide a NumPy-compatible interface: https://numpy.org/doc/stable/user/whatisnumpy.html
Examples:
>>> from itertools import pairwise
>>> import pydrex
>>> olA = Mineral(
... phase=pydrex.MineralPhase.olivine,
... fabric=pydrex.MineralFabric.olivine_A,
... regime=pydrex.DeformationRegime.matrix_dislocation,
... n_grains=pydrex.DefaultParams().number_of_grains,
... )
>>> def get_velocity_gradient(t, x): # Simple L for simple shear.
... L = np.zeros((3, 3))
... L[0, 2] = 1
... return L
>>> def get_position(t):
... return np.zeros(3) # Stationary polycrystal for this example.
>>> timestamps = range(2) # Just 1 timestep for demonstration.
>>> deformation_gradient = np.eye(3) # Start with an undeformed polycrystal.
>>> for t_start, t_end in pairwise(timestamps):
... # Update deformation_gradient, olA.orientations and olA.fractions.
... deformation_gradient = olA.update_orientations(
... pydrex.DefaultParams().as_dict(),
... deformation_gradient,
... get_velocity_gradient,
... (t_start, t_end, get_position),
... )
>>> from numpy import testing as nt
>>> nt.assert_allclose(deformation_gradient,
... np.eye(3) + get_velocity_gradient(t_end, np.nan),
... atol=1e-15, rtol=0
... )
545 def save(self, filename, postfix=None): 546 """Save CPO data for all stored timesteps to a `numpy` NPZ file. 547 548 If `postfix` is not `None`, the data is appended to the NPZ file 549 in fields ending with "`_postfix`". 550 551 Raises a `ValueError` if the data shapes are not compatible. 552 553 See also: `numpy.savez`, `Mineral.load`, `Mineral.from_file`. 554 555 """ 556 if len(self.fractions) != len(self.orientations): 557 raise ValueError( 558 "Length of stored results must match." 559 + " You've supplied currupted data with:\n" 560 + f"- {len(self.fractions)} grain size results, and\n" 561 + f"- {len(self.orientations)} orientation results." 562 ) 563 if self.fractions[0].shape[0] == self.orientations[0].shape[0] == self.n_grains: 564 data = { 565 "meta": np.array( 566 [self.phase, self.fabric, self.regime], dtype=np.uint8 567 ), 568 "fractions": np.stack(self.fractions), 569 "orientations": np.stack(self.orientations), 570 } 571 # Create parent directories, resolve relative paths. 572 _io.resolve_path(filename) 573 # Append to file, requires postfix (unique name). 574 if postfix is not None: 575 _log.info("saving Mineral to file %s (postfix: %s)", filename, postfix) 576 archive = ZipFile(filename, mode="a", allowZip64=True) 577 for key in data.keys(): 578 with archive.open( 579 f"{key}_{postfix}", "w", force_zip64=True 580 ) as file: 581 buffer = io.BytesIO() 582 np.save(buffer, data[key]) 583 file.write(buffer.getvalue()) 584 buffer.close() 585 else: 586 _log.info("saving Mineral to file %s", filename) 587 np.savez(filename, **data) 588 else: 589 raise ValueError( 590 "Size of CPO data arrays must match number of grains." 591 + " You've supplied corrupted data with:\n" 592 + f"- `n_grains = {self.n_grains}`,\n" 593 + f"- `fractions[0].shape = {self.fractions[0].shape}`, and\n" 594 + f"- `orientations[0].shape = {self.orientations[0].shape}`." 595 )
Save CPO data for all stored timesteps to a numpy
NPZ file.
If postfix
is not None
, the data is appended to the NPZ file
in fields ending with "_postfix
".
Raises a ValueError
if the data shapes are not compatible.
See also: numpy.savez
, Mineral.load
, Mineral.from_file
.
597 def load(self, filename, postfix=None): 598 """Load CPO data from a `numpy` NPZ file. 599 600 If `postfix` is not `None`, data is read from fields ending with "`_postfix`". 601 602 See also: `Mineral.save`, `Mineral.from_file`. 603 604 """ 605 if not filename.endswith(".npz"): 606 raise ValueError( 607 f"Must only load from numpy NPZ format. Cannot load from {filename}." 608 ) 609 data = np.load(filename) 610 if postfix is not None: 611 phase, fabric, regime = data[f"meta_{postfix}"] 612 self.fractions = list(data[f"fractions_{postfix}"]) 613 self.orientations = list(data[f"orientations_{postfix}"]) 614 else: 615 phase, fabric, regime = data["meta"] 616 self.fractions = list(data["fractions"]) 617 self.orientations = list(data["orientations"]) 618 619 self.phase = phase 620 self.fabric = fabric 621 self.regime = regime 622 self.orientations_init = self.orientations[0] 623 self.fractions_init = self.fractions[0]
Load CPO data from a numpy
NPZ file.
If postfix
is not None
, data is read from fields ending with "_postfix
".
See also: Mineral.save
, Mineral.from_file
.
625 @classmethod 626 def from_file(cls, filename, postfix=None): 627 """Construct a `Mineral` instance using data from a `numpy` NPZ file. 628 629 If `postfix` is not `None`, data is read from fields ending with “`_postfix`”. 630 631 See also: `Mineral.save`, `Mineral.load`. 632 633 """ 634 if not filename.endswith(".npz"): 635 raise ValueError( 636 f"Must only load from numpy NPZ format. Cannot load from {filename}." 637 ) 638 data = np.load(filename) 639 if postfix is not None: 640 phase, fabric, regime = data[f"meta_{postfix}"] 641 fractions = list(data[f"fractions_{postfix}"]) 642 orientations = list(data[f"orientations_{postfix}"]) 643 else: 644 phase, fabric, regime = data["meta"] 645 fractions = list(data["fractions"]) 646 orientations = list(data["orientations"]) 647 648 mineral = cls( 649 phase, 650 fabric, 651 regime, 652 n_grains=len(fractions[0]), 653 fractions_init=fractions[0], 654 orientations_init=orientations[0], 655 ) 656 mineral.fractions = fractions 657 mineral.orientations = orientations 658 return mineral
Construct a Mineral
instance using data from a numpy
NPZ file.
If postfix
is not None
, data is read from fields ending with “_postfix
”.
See also: Mineral.save
, Mineral.load
.
661def update_all( 662 minerals: list[Mineral], 663 params: dict, 664 deformation_gradient: np.ndarray, 665 get_velocity_gradient, 666 pathline: tuple, 667 get_regime=None, 668 **kwargs, 669) -> np.ndarray: 670 """Update orientations and volume distributions for all mineral phases. 671 672 Returns the updated deformation gradient tensor which measures the accumulated 673 macroscopic strain. 674 675 """ 676 for i, mineral in enumerate(minerals): 677 # Deformation gradient is independent of mineral phase. 678 new_deformation_gradient = mineral.update_orientations( 679 params=params, 680 deformation_gradient=deformation_gradient, 681 get_velocity_gradient=get_velocity_gradient, 682 pathline=pathline, 683 get_regime=get_regime, 684 **kwargs, 685 ) 686 return new_deformation_gradient
Update orientations and volume distributions for all mineral phases.
Returns the updated deformation gradient tensor which measures the accumulated macroscopic strain.
690def voigt_averages( 691 minerals: list[Mineral], 692 phase_assemblage: list[_core.MineralPhase], 693 phase_fractions: list[float], 694 elastic_tensors: StiffnessTensors = StiffnessTensors(), 695): 696 """Calculate elastic tensors as the Voigt averages of a collection of `mineral`s. 697 698 - `minerals` — mineral phases storing orientations and fractional volumes of grains 699 - `phase_assemblage` — collection of unique mineral phases in the aggregate 700 - `phase_fractions` — collection of volume fractions for each phase in 701 `phase_assemblage` (values should sum to 1). 702 703 Raises a ValueError if the minerals contain an unequal number of grains or stored 704 texture results. 705 706 """ 707 n_grains = minerals[0].n_grains 708 if not np.all([m.n_grains == n_grains for m in minerals[1:]]): 709 raise ValueError("cannot average minerals with unequal grain counts") 710 n_steps = len(minerals[0].orientations) 711 if not np.all([len(m.orientations) == n_steps for m in minerals[1:]]): 712 raise ValueError( 713 "cannot average minerals with variable-length orientation arrays" 714 ) 715 if not np.all([len(m.fractions) == n_steps for m in minerals]): 716 raise ValueError( 717 "cannot average minerals with variable-length grain volume arrays" 718 ) 719 720 # TODO: Perform rotation directly on the 6x6 matrices, see Carcione 2007. 721 # This trick is implemented in cpo_elastic_tensor.cc in Aspect. 722 average_tensors = np.zeros((n_steps, 6, 6)) 723 for i in range(n_steps): 724 for mineral in minerals: 725 for n in range(n_grains): 726 match mineral.phase: 727 case _core.MineralPhase.olivine: 728 average_tensors[i] += _tensors.elastic_tensor_to_voigt( 729 _tensors.rotate( 730 elastic_tensors.olivine, 731 mineral.orientations[i][n, ...].transpose(), 732 ) 733 * mineral.fractions[i][n] 734 * phase_fractions[phase_assemblage.index(mineral.phase)] 735 ) 736 case _core.MineralPhase.enstatite: 737 average_tensors[i] += _tensors.elastic_tensor_to_voigt( 738 _tensors.rotate( 739 elastic_tensors.enstatite, 740 mineral.orientations[i][n, ...].transpose(), 741 ) 742 * mineral.fractions[i][n] 743 * phase_fractions[phase_assemblage.index(mineral.phase)] 744 ) 745 case _: 746 raise ValueError(f"unsupported mineral phase: {mineral.phase}") 747 return average_tensors
Calculate elastic tensors as the Voigt averages of a collection of mineral
s.
minerals
— mineral phases storing orientations and fractional volumes of grainsphase_assemblage
— collection of unique mineral phases in the aggregatephase_fractions
— collection of volume fractions for each phase inphase_assemblage
(values should sum to 1).
Raises a ValueError if the minerals contain an unequal number of grains or stored texture results.