tests.test_simple_shear_2d
PyDRex: 2D simple shear tests.
1"""> PyDRex: 2D simple shear tests.""" 2 3import contextlib as cl 4import functools as ft 5from time import process_time 6 7import numpy as np 8import pytest 9from numpy import asarray as Ŋ 10from numpy import testing as nt 11from scipy.interpolate import PchipInterpolator 12 13from pydrex import core as _core 14from pydrex import diagnostics as _diagnostics 15from pydrex import geometry as _geo 16from pydrex import io as _io 17from pydrex import logger as _log 18from pydrex import minerals as _minerals 19from pydrex import pathlines as _paths 20from pydrex import stats as _stats 21from pydrex import utils as _utils 22from pydrex import velocity as _velocity 23from pydrex import visualisation as _vis 24 25Pool, HAS_RAY = _utils.import_proc_pool() 26 27# Subdirectory of `outdir` used to store outputs from these tests. 28SUBDIR = "2d_simple_shear" 29 30 31class TestPreliminaries: 32 """Preliminary tests to check that various auxiliary routines are working.""" 33 34 def test_strain_increment(self): 35 """Test for accumulating strain via strain increment calculations.""" 36 _, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1) 37 timestamps = np.linspace(0, 1, 10) # Solve until D₀t=1 (tensorial strain). 38 strains_inc = np.zeros_like(timestamps) 39 L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0])) 40 for i, ε in enumerate(strains_inc[1:]): 41 strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment( 42 timestamps[1] - timestamps[0], 43 L, 44 ) 45 # For constant timesteps, check strains == positive_timestamps * strain_rate. 46 nt.assert_allclose(strains_inc, timestamps, atol=6e-16, rtol=0) 47 48 # Same thing, but for strain rate similar to experiments. 49 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", 1e-5) 50 timestamps = np.linspace(0, 1e6, 10) # Solve until D₀t=10 (tensorial strain). 51 strains_inc = np.zeros_like(timestamps) 52 L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0])) 53 for i, ε in enumerate(strains_inc[1:]): 54 strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment( 55 timestamps[1] - timestamps[0], 56 L, 57 ) 58 nt.assert_allclose(strains_inc, timestamps * 1e-5, atol=5e-15, rtol=0) 59 60 # Again, but this time the particle will move (using get_pathline). 61 # We use a 400km x 400km box and a strain rate of 1e-15 s⁻¹. 62 get_velocity, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1e-15) 63 timestamps, get_position = _paths.get_pathline( 64 Ŋ([1e5, 0e0, 1e5]), 65 get_velocity, 66 get_velocity_gradient, 67 Ŋ([-2e5, 0e0, -2e5]), 68 Ŋ([2e5, 0e0, 2e5]), 69 2, 70 regular_steps=10, 71 ) 72 positions = [get_position(t) for t in timestamps] 73 velocity_gradients = [get_velocity_gradient(np.nan, Ŋ(x)) for x in positions] 74 75 # Check that polycrystal is experiencing steady velocity gradient. 76 nt.assert_array_equal( 77 velocity_gradients, np.full_like(velocity_gradients, velocity_gradients[0]) 78 ) 79 # Check that positions are changing as expected. 80 xdiff = np.diff(Ŋ([x[0] for x in positions])) 81 zdiff = np.diff(Ŋ([x[2] for x in positions])) 82 assert xdiff[0] > 0 83 assert zdiff[0] == 0 84 nt.assert_allclose(xdiff, np.full_like(xdiff, xdiff[0]), rtol=0, atol=1e-10) 85 nt.assert_allclose(zdiff, np.full_like(zdiff, zdiff[0]), rtol=0, atol=1e-10) 86 strains_inc = np.zeros_like(timestamps) 87 for t, time in enumerate(timestamps[:-1], start=1): 88 strains_inc[t] = strains_inc[t - 1] + ( 89 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 90 ) 91 # fig, ax, _, _ = _vis.pathline_box2d( 92 # None, 93 # get_velocity, 94 # "xz", 95 # strains_inc, 96 # positions, 97 # ".", 98 # Ŋ([-2e5, -2e5]), 99 # Ŋ([2e5, 2e5]), 100 # [20, 20], 101 # ) 102 # fig.savefig("/tmp/fig.png") 103 nt.assert_allclose( 104 strains_inc, 105 (timestamps - timestamps[0]) * 1e-15, 106 atol=5e-15, 107 rtol=0, 108 ) 109 110 111class TestOlivineA: 112 """Tests for stationary A-type olivine polycrystals in 2D simple shear.""" 113 114 class_id = "olivineA" 115 116 @classmethod 117 def run( 118 cls, 119 params, 120 timestamps, 121 strain_rate, 122 get_velocity_gradient, 123 shear_direction, 124 seed=None, 125 return_fse=None, 126 get_position=lambda t: np.zeros(3), # Stationary particles by default. 127 ): 128 """Reusable logic for 2D olivine (A-type) simple shear tests. 129 130 Returns a tuple with the mineral and the FSE angle (or `None` if `return_fse` is 131 `None`). 132 133 """ 134 mineral = _minerals.Mineral( 135 phase=_core.MineralPhase.olivine, 136 fabric=_core.MineralFabric.olivine_A, 137 regime=_core.DeformationRegime.matrix_dislocation, 138 n_grains=params["number_of_grains"], 139 seed=seed, 140 ) 141 deformation_gradient = np.eye(3) # Undeformed initial state. 142 θ_fse = np.empty_like(timestamps) 143 θ_fse[0] = 45 144 145 for t, time in enumerate(timestamps[:-1], start=1): 146 # Set up logging message depending on dynamic parameter and seeds. 147 msg_start = ( 148 f"N = {params['number_of_grains']}; " 149 + f"λ∗ = {params['nucleation_efficiency']}; " 150 + f"X = {params['gbs_threshold']}; " 151 + f"M∗ = {params['gbm_mobility']}; " 152 ) 153 if seed is not None: 154 msg_start += f"# {seed}; " 155 156 _log.info(msg_start + "step %s/%s (t = %s)", t, len(timestamps) - 1, time) 157 158 deformation_gradient = mineral.update_orientations( 159 params, 160 deformation_gradient, 161 get_velocity_gradient, 162 pathline=(time, timestamps[t], get_position), 163 ) 164 _log.debug( 165 "› velocity gradient = %s", 166 get_velocity_gradient(np.nan, np.full(3, np.nan)).flatten(), 167 ) 168 _log.debug("› strain D₀t = %.2f", strain_rate * timestamps[t]) 169 _log.debug( 170 "› grain fractions: median = %s, max = %s, min = %s", 171 np.median(mineral.fractions[-1]), 172 np.max(mineral.fractions[-1]), 173 np.min(mineral.fractions[-1]), 174 ) 175 if return_fse: 176 _, fse_v = _diagnostics.finite_strain(deformation_gradient) 177 θ_fse[t] = _diagnostics.smallest_angle(fse_v, shear_direction) 178 else: 179 θ_fse = None 180 181 return mineral, θ_fse 182 183 @classmethod 184 def interp_GBM_Kaminski2001(cls, strains): 185 """Interpolate Kaminski & Ribe, 2001 data to get target angles at `strains`.""" 186 _log.info("interpolating target CPO angles...") 187 data = _io.read_scsv(_io.data("thirdparty") / "Kaminski2001_GBMshear.scsv") 188 cs_M0 = PchipInterpolator( 189 _utils.remove_nans(data.equivalent_strain_M0) / 200, 190 _utils.remove_nans(data.angle_M0), 191 ) 192 cs_M50 = PchipInterpolator( 193 _utils.remove_nans(data.equivalent_strain_M50) / 200, 194 _utils.remove_nans(data.angle_M50), 195 ) 196 cs_M200 = PchipInterpolator( 197 _utils.remove_nans(data.equivalent_strain_M200) / 200, 198 _utils.remove_nans(data.angle_M200), 199 ) 200 return [cs_M0(strains), cs_M50(strains), cs_M200(strains)] 201 202 @classmethod 203 def interp_GBM_FortranDRex(cls, strains): 204 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 205 _log.info("interpolating target CPO angles...") 206 data = _io.read_scsv(_io.data("drexF90") / "olA_D1E4_dt50_X0_L5.scsv") 207 data_strains = np.linspace(0, 1, 200) 208 cs_M0 = PchipInterpolator(data_strains, _utils.remove_nans(data.M0_angle)) 209 cs_M50 = PchipInterpolator(data_strains, _utils.remove_nans(data.M50_angle)) 210 cs_M200 = PchipInterpolator(data_strains, _utils.remove_nans(data.M200_angle)) 211 return [cs_M0(strains), cs_M50(strains), cs_M200(strains)] 212 213 @classmethod 214 def interp_GBS_FortranDRex(cls, strains): 215 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 216 _log.info("interpolating target CPO angles...") 217 data = _io.read_scsv(_io.data("thirdparty") / "a_axis_GBS_fortran.scsv") 218 data_strains = np.linspace(0, 1, 200) 219 cs_X0 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X0)) 220 cs_X20 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X20)) 221 cs_X40 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X40)) 222 return [cs_X0(strains), cs_X20(strains), cs_X40(strains)] 223 224 @classmethod 225 def interp_GBS_long_FortranDRex(cls, strains): 226 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 227 _log.info("interpolating target CPO angles...") 228 data = _io.read_scsv(_io.data("thirdparty") / "a_axis_GBS_long_fortran.scsv") 229 data_strains = np.linspace(0, 2.5, 500) 230 cs_X0 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X0)) 231 cs_X20 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X20)) 232 cs_X40 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X40)) 233 return [cs_X0(strains), cs_X20(strains), cs_X40(strains)] 234 235 @classmethod 236 def interp_GBS_Kaminski2004(cls, strains): 237 """Interpolate Kaminski & Ribe, 2001 data to get target angles at `strains`.""" 238 _log.info("interpolating target CPO angles...") 239 data = _io.read_scsv(_io.data("thirdparty") / "Kaminski2004_GBSshear.scsv") 240 cs_X0 = PchipInterpolator( 241 _utils.remove_nans(data.dimensionless_time_X0), 242 45 + _utils.remove_nans(data.angle_X0), 243 ) 244 cs_X0d2 = PchipInterpolator( 245 _utils.remove_nans(data.dimensionless_time_X0d2), 246 45 + _utils.remove_nans(data.angle_X0d2), 247 ) 248 cs_X0d4 = PchipInterpolator( 249 _utils.remove_nans(data.dimensionless_time_X0d4), 250 45 + _utils.remove_nans(data.angle_X0d4), 251 ) 252 return [cs_X0(strains), cs_X0d2(strains), cs_X0d4(strains)] 253 254 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 255 def test_zero_recrystallisation(self, seed): 256 """Check that M*=0 is a reliable switch to turn off recrystallisation.""" 257 params = _core.DefaultParams().as_dict() 258 params["gbm_mobility"] = 0 259 strain_rate = 1 260 timestamps = np.linspace(0, 1, 25) # Solve until D₀t=1 (tensorial strain). 261 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 262 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 263 mineral, _ = self.run( 264 params, 265 timestamps, 266 strain_rate, 267 get_velocity_gradient, 268 shear_direction, 269 seed=seed, 270 ) 271 for fractions in mineral.fractions[1:]: 272 nt.assert_allclose(fractions, mineral.fractions[0], atol=1e-15, rtol=0) 273 274 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 275 @pytest.mark.parametrize("gbm_mobility", [50, 100, 150]) 276 def test_grainsize_median(self, seed, gbm_mobility): 277 """Check that M*={50,100,150}, λ*=5 causes decreasing grain size median.""" 278 params = _core.DefaultParams().as_dict() 279 params["gbm_mobility"] = gbm_mobility 280 params["nucleation_efficiency"] = 5 281 strain_rate = 1 282 timestamps = np.linspace(0, 1, 25) # Solve until D₀t=1 (tensorial strain). 283 n_timestamps = len(timestamps) 284 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 285 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 286 mineral, _ = self.run( 287 params, 288 timestamps, 289 strain_rate, 290 get_velocity_gradient, 291 shear_direction, 292 seed=seed, 293 ) 294 medians = np.empty(n_timestamps) 295 for i, fractions in enumerate(mineral.fractions): 296 medians[i] = np.median(fractions) 297 298 # The first diff is positive (~1e-6) before nucleation sets in. 299 nt.assert_array_less(np.diff(medians)[1:], np.full(n_timestamps - 2, 0)) 300 301 @pytest.mark.slow 302 @pytest.mark.parametrize("gbs_threshold", [0, 0.2, 0.4]) 303 @pytest.mark.parametrize("nucleation_efficiency", [3, 5, 10]) 304 def test_dvdx_ensemble( 305 self, outdir, seeds_nearX45, ncpus, gbs_threshold, nucleation_efficiency 306 ): 307 r"""Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X). 308 309 Velocity gradient: 310 $$\bm{L} = \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix}$$ 311 312 """ 313 strain_rate = 1 314 timestamps = np.linspace(0, 1, 201) # Solve until D₀t=1 ('shear' γ=2). 315 n_timestamps = len(timestamps) 316 # Use `seeds` instead of `seeds_nearX45` if you have even more RAM and CPU time. 317 _seeds = seeds_nearX45 318 n_seeds = len(_seeds) 319 320 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 321 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 322 323 gbm_mobilities = [0, 50, 125, 200] 324 markers = ("x", "*", "d", "s") 325 326 _id = f"X{_io.stringify(gbs_threshold)}_L{_io.stringify(nucleation_efficiency)}" 327 # Output setup with optional logging and data series labels. 328 θ_fse = np.empty_like(timestamps) 329 angles = np.empty((len(gbm_mobilities), n_seeds, n_timestamps)) 330 optional_logging = cl.nullcontext() 331 if outdir is not None: 332 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_dvdx_ensemble_{_id}" 333 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 334 labels = [] 335 336 with optional_logging: 337 clock_start = process_time() 338 for m, gbm_mobility in enumerate(gbm_mobilities): 339 if m == 0: 340 return_fse = True 341 else: 342 return_fse = False 343 344 params = { 345 "phase_assemblage": (_core.MineralPhase.olivine,), 346 "phase_fractions": (1.0,), 347 "enstatite_fraction": 0.0, 348 "stress_exponent": 1.5, 349 "deformation_exponent": 3.5, 350 "gbm_mobility": gbm_mobility, 351 "gbs_threshold": gbs_threshold, 352 "nucleation_efficiency": nucleation_efficiency, 353 "number_of_grains": 5000, 354 "initial_olivine_fabric": "A", 355 } 356 357 _run = ft.partial( 358 self.run, 359 params, 360 timestamps, 361 strain_rate, 362 get_velocity_gradient, 363 shear_direction, 364 return_fse=return_fse, 365 ) 366 with Pool(processes=ncpus) as pool: 367 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 368 mineral, fse_angles = out 369 angles[m, s, :] = [ 370 _diagnostics.smallest_angle(v, shear_direction) 371 for v in _diagnostics.elasticity_components( 372 _minerals.voigt_averages([mineral], params) 373 )["hexagonal_axis"] 374 ] 375 # Save the whole mineral for the first seed only. 376 if outdir is not None and s == 0: 377 postfix = ( 378 f"M{_io.stringify(gbm_mobility)}" 379 + f"_X{_io.stringify(gbs_threshold)}" 380 + f"_L{_io.stringify(nucleation_efficiency)}" 381 ) 382 mineral.save(f"{out_basepath}.npz", postfix=postfix) 383 if return_fse: 384 θ_fse += fse_angles 385 386 if return_fse: 387 θ_fse /= n_seeds 388 389 if outdir is not None: 390 labels.append(f"$M^∗$ = {gbm_mobility}") 391 392 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 393 394 # Take ensemble means and optionally plot figure. 395 strains = timestamps * strain_rate 396 _log.info("postprocessing results for %s", _id) 397 result_angles = angles.mean(axis=1) 398 result_angles_err = angles.std(axis=1) 399 400 if outdir is not None: 401 schema = { 402 "delimiter": ",", 403 "missing": "-", 404 "fields": [ 405 { 406 "name": "strain", 407 "type": "integer", 408 "unit": "percent", 409 "fill": 999999, 410 } 411 ], 412 } 413 np.savez( 414 f"{out_basepath}.npz", 415 angles=result_angles, 416 angles_err=result_angles_err, 417 ) 418 _io.save_scsv( 419 f"{out_basepath}_strains.scsv", 420 schema, 421 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 422 ) 423 fig, ax, colors = _vis.alignment( 424 None, 425 strains, 426 result_angles, 427 markers, 428 labels, 429 err=result_angles_err, 430 θ_max=60, 431 θ_fse=θ_fse, 432 ) 433 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 434 435 @pytest.mark.slow 436 def test_dvdx_GBM(self, outdir, seeds_nearX45, ncpus): 437 r"""Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X). 438 439 Velocity gradient: 440 $$ 441 \bm{L} = 10^{-4} × 442 \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} 443 $$ 444 445 Results are compared to the Fortran 90 output. 446 447 """ 448 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 449 strain_rate = 1e-4 450 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 451 timestamps = np.linspace(0, 1e4, 51) # Solve until D₀t=1 ('shear' γ=2). 452 i_strain_40p = 10 # Index of 40% strain, lower strains are not relevant here. 453 i_strain_100p = 25 # Index of 100% strain, when M*=0 matches FSE. 454 params = _core.DefaultParams().as_dict() 455 params["gbs_threshold"] = 0 # No GBS, to match the Fortran parameters. 456 gbm_mobilities = (0, 10, 50, 125, 200) # Must be in ascending order. 457 markers = ("x", ".", "*", "d", "s") 458 # Use `seeds` instead of `seeds_nearX45` if you have even more RAM and CPU time. 459 _seeds = seeds_nearX45 460 n_seeds = len(_seeds) 461 angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps))) 462 θ_fse = np.zeros_like(timestamps) 463 strains = timestamps * strain_rate 464 M0_drexF90, M50_drexF90, M200_drexF90 = self.interp_GBM_FortranDRex(strains) 465 466 optional_logging = cl.nullcontext() 467 if outdir is not None: 468 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_mobility" 469 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 470 labels = [] 471 472 with optional_logging: 473 clock_start = process_time() 474 for m, gbm_mobility in enumerate(gbm_mobilities): 475 if m == 0: 476 return_fse = True 477 else: 478 return_fse = False 479 params["gbm_mobility"] = gbm_mobility 480 481 _run = ft.partial( 482 self.run, 483 params, 484 timestamps, 485 strain_rate, 486 get_velocity_gradient, 487 shear_direction, 488 return_fse=True, 489 ) 490 with Pool(processes=ncpus) as pool: 491 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 492 mineral, fse_angles = out 493 angles[m, s, :] = [ 494 _diagnostics.smallest_angle(v, shear_direction) 495 for v in _diagnostics.elasticity_components( 496 _minerals.voigt_averages([mineral], params) 497 )["hexagonal_axis"] 498 ] 499 # Save the whole mineral for the first seed only. 500 if outdir is not None and s == 0: 501 mineral.save( 502 f"{out_basepath}.npz", 503 postfix=f"M{_io.stringify(gbm_mobility)}", 504 ) 505 if return_fse: 506 θ_fse += fse_angles 507 508 if return_fse: 509 θ_fse /= n_seeds 510 511 if outdir is not None: 512 labels.append(f"$M^∗$ = {params['gbm_mobility']}") 513 514 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 515 516 # Take ensemble means and optionally plot figure. 517 _log.info("postprocessing results...") 518 result_angles = angles.mean(axis=1) 519 result_angles_err = angles.std(axis=1) 520 521 if outdir is not None: 522 schema = { 523 "delimiter": ",", 524 "missing": "-", 525 "fields": [ 526 { 527 "name": "strain", 528 "type": "integer", 529 "unit": "percent", 530 "fill": 999999, 531 } 532 ], 533 } 534 _io.save_scsv( 535 f"{out_basepath}_strains.scsv", 536 schema, 537 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 538 ) 539 np.savez( 540 f"{out_basepath}_angles.npz", 541 angles=result_angles, 542 err=result_angles_err, 543 ) 544 fig, ax, colors = _vis.alignment( 545 None, 546 strains, 547 result_angles, 548 markers, 549 labels, 550 err=result_angles_err, 551 θ_max=60, 552 θ_fse=θ_fse, 553 ) 554 ax.plot(strains, M0_drexF90, c=colors[0]) 555 ax.plot(strains, M50_drexF90, c=colors[2]) 556 ax.plot(strains, M200_drexF90, c=colors[4]) 557 _vis.show_Skemer2016_ShearStrainAngles( 558 ax, 559 ["Z&K 1200 C", "Z&K 1300 C"], 560 ["v", "^"], 561 ["k", "k"], 562 ["none", None], 563 [ 564 "Zhang & Karato, 1995\n(1473 K)", 565 "Zhang & Karato, 1995\n(1573 K)", 566 ], 567 _core.MineralFabric.olivine_A, 568 ) 569 # There is a lot of stuff on this legend, so put it outside the axes. 570 # These values might need to be tweaked depending on the font size, etc. 571 _legend = _utils.redraw_legend(ax, fig=fig, bbox_to_anchor=(1.66, 0.99)) 572 fig.savefig( 573 _io.resolve_path(f"{out_basepath}.pdf"), 574 bbox_extra_artists=(_legend,), 575 bbox_inches="tight", 576 ) 577 578 # Check that GBM speeds up the alignment between 40% and 100% strain. 579 _log.info("checking grain orientations...") 580 for i, θ in enumerate(result_angles[:-1], start=1): 581 nt.assert_array_less( 582 result_angles[i][i_strain_40p:i_strain_100p], 583 θ[i_strain_40p:i_strain_100p], 584 ) 585 586 # Check that M*=0 matches FSE (±1°) past 100% strain. 587 nt.assert_allclose( 588 result_angles[0][i_strain_100p:], 589 θ_fse[i_strain_100p:], 590 atol=1, 591 rtol=0, 592 ) 593 594 # Check that results match Fortran output past 40% strain. 595 nt.assert_allclose( 596 result_angles[0][i_strain_40p:], 597 M0_drexF90[i_strain_40p:], 598 atol=0, 599 rtol=0.1, # At 40% strain the match is worse than at higher strain. 600 ) 601 nt.assert_allclose( 602 result_angles[2][i_strain_40p:], 603 M50_drexF90[i_strain_40p:], 604 atol=1, 605 rtol=0, 606 ) 607 nt.assert_allclose( 608 result_angles[4][i_strain_40p:], 609 M200_drexF90[i_strain_40p:], 610 atol=1.5, 611 rtol=0, 612 ) 613 614 @pytest.mark.slow 615 def test_GBM_calibration(self, outdir, seeds, ncpus): 616 r"""Compare results for various values of $$M^∗$$ to A-type olivine data. 617 618 Velocity gradient: 619 $$ 620 \bm{L} = 10^{-4} × 621 \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} 622 $$ 623 624 Unlike `test_dvdx_GBM`, 625 grain boudary sliding is enabled here (see `_core.DefaultParams`). 626 Data are provided by [Skemer & Hansen, 2016](http://dx.doi.org/10.1016/j.tecto.2015.12.003). 627 628 """ 629 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 630 strain_rate = 1 631 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 632 timestamps = np.linspace(0, 3.2, 65) # Solve until D₀t=3.2 ('shear' γ=6.4). 633 params = _core.DefaultParams().as_dict() 634 params["number_of_grains"] = 5000 635 gbm_mobilities = (0, 10, 50, 125) # Must be in ascending order. 636 markers = ("x", "*", "1", ".") 637 # Uses 100 seeds by default; use all 1000 if you have more RAM and CPU time. 638 _seeds = seeds[:100] 639 n_seeds = len(_seeds) 640 angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps))) 641 θ_fse = np.zeros_like(timestamps) 642 strains = timestamps * strain_rate 643 644 optional_logging = cl.nullcontext() 645 if outdir is not None: 646 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_calibration" 647 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 648 labels = [] 649 650 with optional_logging: 651 clock_start = process_time() 652 for m, gbm_mobility in enumerate(gbm_mobilities): 653 return_fse = True if m == 0 else False 654 params["gbm_mobility"] = gbm_mobility 655 _run = ft.partial( 656 self.run, 657 params, 658 timestamps, 659 strain_rate, 660 get_velocity_gradient, 661 shear_direction, 662 return_fse=return_fse, 663 ) 664 with Pool(processes=ncpus) as pool: 665 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 666 mineral, fse_angles = out 667 angles[m, s, :] = [ 668 _diagnostics.smallest_angle(v, shear_direction) 669 for v in _diagnostics.elasticity_components( 670 _minerals.voigt_averages([mineral], params) 671 )["hexagonal_axis"] 672 ] 673 # Save the whole mineral for the first seed only. 674 if outdir is not None and s == 0: 675 mineral.save( 676 f"{out_basepath}.npz", 677 postfix=f"M{_io.stringify(gbm_mobility)}", 678 ) 679 if return_fse: 680 θ_fse += fse_angles 681 682 if return_fse: 683 θ_fse /= n_seeds 684 685 if outdir is not None: 686 labels.append(f"$M^∗$ = {params['gbm_mobility']}") 687 688 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 689 690 # Take ensemble means and optionally plot figure. 691 _log.info("postprocessing results...") 692 result_angles = angles.mean(axis=1) 693 result_angles_err = angles.std(axis=1) 694 695 if outdir is not None: 696 schema = { 697 "delimiter": ",", 698 "missing": "-", 699 "fields": [ 700 { 701 "name": "strain", 702 "type": "integer", 703 "unit": "percent", 704 "fill": 999999, 705 } 706 ], 707 } 708 _io.save_scsv( 709 f"{out_basepath}_strains.scsv", 710 schema, 711 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 712 ) 713 np.savez( 714 _io.resolve_path(f"{out_basepath}_ensemble_means.npz"), 715 angles=result_angles, 716 err=result_angles_err, 717 ) 718 fig = _vis.figure( 719 figsize=(_vis.DEFAULT_FIG_WIDTH * 3, _vis.DEFAULT_FIG_HEIGHT) 720 ) 721 fig, ax, colors = _vis.alignment( 722 fig.add_subplot(), 723 strains, 724 result_angles, 725 markers, 726 labels, 727 err=result_angles_err, 728 θ_max=80, 729 θ_fse=θ_fse, 730 ) 731 ( 732 _, 733 _, 734 _, 735 data_Skemer2016, 736 indices, 737 ) = _vis.show_Skemer2016_ShearStrainAngles( 738 ax, 739 [ 740 "Z&K 1200 C", 741 "Z&K 1300 C", 742 "Skemer 2011", 743 "Hansen 2014", 744 "Warren 2008", 745 "Webber 2010", 746 "H&W 2015", 747 ], 748 ["v", "^", "o", "s", "v", "o", "s"], 749 ["k", "k", "k", "k", "k", "k", "k"], 750 ["none", "none", "none", "none", None, None, None], 751 [ 752 "Zhang & Karato, 1995 (1473 K)", 753 "Zhang & Karato, 1995 (1573 K)", 754 "Skemer et al., 2011 (1500 K)", 755 "Hansen et al., 2014 (1473 K)", 756 "Warren et al., 2008", 757 "Webber et al., 2010", 758 "Hansen & Warren, 2015", 759 ], 760 fabric=_core.MineralFabric.olivine_A, 761 ) 762 _legend = _utils.redraw_legend(ax, loc="upper right", ncols=3) 763 fig.savefig( 764 _io.resolve_path(f"{out_basepath}.pdf"), 765 bbox_extra_artists=(_legend,), 766 bbox_inches="tight", 767 ) 768 r2vals = [] 769 for angles in result_angles: 770 _angles = PchipInterpolator(strains, angles) 771 r2 = np.sum( 772 [ 773 (a - b) ** 2 774 for a, b in zip( 775 _angles( 776 np.take(data_Skemer2016.shear_strain, indices) / 200 777 ), 778 np.take(data_Skemer2016.angle, indices), 779 ) 780 ] 781 ) 782 r2vals.append(r2) 783 _log.info( 784 "Sums of squared residuals (r-values) for each M∗: %s", r2vals 785 ) 786 787 @pytest.mark.big 788 def test_dudz_pathline(self, outdir, seed): 789 """Test alignment of olivine a-axis for a polycrystal advected on a pathline.""" 790 test_id = "dudz_pathline" 791 optional_logging = cl.nullcontext() 792 if outdir is not None: 793 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}" 794 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 795 796 with optional_logging: 797 shear_direction = Ŋ([1, 0, 0], dtype=np.float64) 798 strain_rate = 1e-15 # Moderate, realistic shear in the upper mantle. 799 get_velocity, get_velocity_gradient = _velocity.simple_shear_2d( 800 "X", "Z", strain_rate 801 ) 802 n_timesteps = 10 803 timestamps, get_position = _paths.get_pathline( 804 Ŋ([1e5, 0e0, 1e5]), 805 get_velocity, 806 get_velocity_gradient, 807 Ŋ([-2e5, 0e0, -2e5]), 808 Ŋ([2e5, 0e0, 2e5]), 809 2, 810 regular_steps=n_timesteps, 811 ) 812 positions = [get_position(t) for t in timestamps] 813 velocity_gradients = [ 814 get_velocity_gradient(np.nan, Ŋ(x)) for x in positions 815 ] 816 817 params = _core.DefaultParams().as_dict() 818 params["gbm_mobility"] = 10 819 params["number_of_grains"] = 5000 820 olA = _minerals.Mineral(n_grains=params["number_of_grains"], seed=seed) 821 deformation_gradient = np.eye(3) 822 strains = np.zeros_like(timestamps) 823 for t, time in enumerate(timestamps[:-1], start=1): 824 strains[t] = strains[t - 1] + ( 825 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 826 ) 827 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 828 deformation_gradient = olA.update_orientations( 829 params, 830 deformation_gradient, 831 get_velocity_gradient, 832 pathline=(time, timestamps[t], get_position), 833 ) 834 835 if outdir is not None: 836 olA.save(f"{out_basepath}.npz") 837 838 orient_resampled, fractions_resampled = _stats.resample_orientations( 839 olA.orientations, olA.fractions, seed=seed 840 ) 841 # About 36GB, 26 min needed with float64. GitHub macos runner has 14GB. 842 misorient_indices = _diagnostics.misorientation_indices( 843 orient_resampled, 844 _geo.LatticeSystem.orthorhombic, 845 ncpus=3, 846 ) 847 cpo_vectors = np.zeros((n_timesteps + 1, 3)) 848 cpo_angles = np.zeros(n_timesteps + 1) 849 for i, matrices in enumerate(orient_resampled): 850 cpo_vectors[i] = _diagnostics.bingham_average( 851 matrices, 852 axis=_minerals.OLIVINE_PRIMARY_AXIS[olA.fabric], 853 ) 854 cpo_angles[i] = _diagnostics.smallest_angle( 855 cpo_vectors[i], shear_direction 856 ) 857 858 # Check for mostly decreasing CPO angles (exclude initial condition). 859 _log.debug("cpo angles: %s", cpo_angles) 860 nt.assert_array_less(np.diff(cpo_angles[1:]), np.ones(n_timesteps - 1)) 861 # Check for increasing CPO strength (M-index). 862 _log.debug("cpo strengths: %s", misorient_indices) 863 nt.assert_array_less( 864 np.full(n_timesteps, -0.01), np.diff(misorient_indices) 865 ) 866 # Check that last angle is <5° (M*=125) or <10° (M*=10). 867 assert cpo_angles[-1] < 10 868 869 if outdir is not None: 870 fig, ax, _, _ = _vis.steady_box2d( 871 None, 872 (get_velocity, [20, 20]), 873 (positions, Ŋ([-2e5, -2e5]), Ŋ([2e5, 2e5])), 874 "xz", 875 (cpo_vectors, misorient_indices), 876 strains, 877 ) 878 fig.savefig(f"{out_basepath}.pdf")
32class TestPreliminaries: 33 """Preliminary tests to check that various auxiliary routines are working.""" 34 35 def test_strain_increment(self): 36 """Test for accumulating strain via strain increment calculations.""" 37 _, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1) 38 timestamps = np.linspace(0, 1, 10) # Solve until D₀t=1 (tensorial strain). 39 strains_inc = np.zeros_like(timestamps) 40 L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0])) 41 for i, ε in enumerate(strains_inc[1:]): 42 strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment( 43 timestamps[1] - timestamps[0], 44 L, 45 ) 46 # For constant timesteps, check strains == positive_timestamps * strain_rate. 47 nt.assert_allclose(strains_inc, timestamps, atol=6e-16, rtol=0) 48 49 # Same thing, but for strain rate similar to experiments. 50 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", 1e-5) 51 timestamps = np.linspace(0, 1e6, 10) # Solve until D₀t=10 (tensorial strain). 52 strains_inc = np.zeros_like(timestamps) 53 L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0])) 54 for i, ε in enumerate(strains_inc[1:]): 55 strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment( 56 timestamps[1] - timestamps[0], 57 L, 58 ) 59 nt.assert_allclose(strains_inc, timestamps * 1e-5, atol=5e-15, rtol=0) 60 61 # Again, but this time the particle will move (using get_pathline). 62 # We use a 400km x 400km box and a strain rate of 1e-15 s⁻¹. 63 get_velocity, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1e-15) 64 timestamps, get_position = _paths.get_pathline( 65 Ŋ([1e5, 0e0, 1e5]), 66 get_velocity, 67 get_velocity_gradient, 68 Ŋ([-2e5, 0e0, -2e5]), 69 Ŋ([2e5, 0e0, 2e5]), 70 2, 71 regular_steps=10, 72 ) 73 positions = [get_position(t) for t in timestamps] 74 velocity_gradients = [get_velocity_gradient(np.nan, Ŋ(x)) for x in positions] 75 76 # Check that polycrystal is experiencing steady velocity gradient. 77 nt.assert_array_equal( 78 velocity_gradients, np.full_like(velocity_gradients, velocity_gradients[0]) 79 ) 80 # Check that positions are changing as expected. 81 xdiff = np.diff(Ŋ([x[0] for x in positions])) 82 zdiff = np.diff(Ŋ([x[2] for x in positions])) 83 assert xdiff[0] > 0 84 assert zdiff[0] == 0 85 nt.assert_allclose(xdiff, np.full_like(xdiff, xdiff[0]), rtol=0, atol=1e-10) 86 nt.assert_allclose(zdiff, np.full_like(zdiff, zdiff[0]), rtol=0, atol=1e-10) 87 strains_inc = np.zeros_like(timestamps) 88 for t, time in enumerate(timestamps[:-1], start=1): 89 strains_inc[t] = strains_inc[t - 1] + ( 90 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 91 ) 92 # fig, ax, _, _ = _vis.pathline_box2d( 93 # None, 94 # get_velocity, 95 # "xz", 96 # strains_inc, 97 # positions, 98 # ".", 99 # Ŋ([-2e5, -2e5]), 100 # Ŋ([2e5, 2e5]), 101 # [20, 20], 102 # ) 103 # fig.savefig("/tmp/fig.png") 104 nt.assert_allclose( 105 strains_inc, 106 (timestamps - timestamps[0]) * 1e-15, 107 atol=5e-15, 108 rtol=0, 109 )
Preliminary tests to check that various auxiliary routines are working.
35 def test_strain_increment(self): 36 """Test for accumulating strain via strain increment calculations.""" 37 _, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1) 38 timestamps = np.linspace(0, 1, 10) # Solve until D₀t=1 (tensorial strain). 39 strains_inc = np.zeros_like(timestamps) 40 L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0])) 41 for i, ε in enumerate(strains_inc[1:]): 42 strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment( 43 timestamps[1] - timestamps[0], 44 L, 45 ) 46 # For constant timesteps, check strains == positive_timestamps * strain_rate. 47 nt.assert_allclose(strains_inc, timestamps, atol=6e-16, rtol=0) 48 49 # Same thing, but for strain rate similar to experiments. 50 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", 1e-5) 51 timestamps = np.linspace(0, 1e6, 10) # Solve until D₀t=10 (tensorial strain). 52 strains_inc = np.zeros_like(timestamps) 53 L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0])) 54 for i, ε in enumerate(strains_inc[1:]): 55 strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment( 56 timestamps[1] - timestamps[0], 57 L, 58 ) 59 nt.assert_allclose(strains_inc, timestamps * 1e-5, atol=5e-15, rtol=0) 60 61 # Again, but this time the particle will move (using get_pathline). 62 # We use a 400km x 400km box and a strain rate of 1e-15 s⁻¹. 63 get_velocity, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1e-15) 64 timestamps, get_position = _paths.get_pathline( 65 Ŋ([1e5, 0e0, 1e5]), 66 get_velocity, 67 get_velocity_gradient, 68 Ŋ([-2e5, 0e0, -2e5]), 69 Ŋ([2e5, 0e0, 2e5]), 70 2, 71 regular_steps=10, 72 ) 73 positions = [get_position(t) for t in timestamps] 74 velocity_gradients = [get_velocity_gradient(np.nan, Ŋ(x)) for x in positions] 75 76 # Check that polycrystal is experiencing steady velocity gradient. 77 nt.assert_array_equal( 78 velocity_gradients, np.full_like(velocity_gradients, velocity_gradients[0]) 79 ) 80 # Check that positions are changing as expected. 81 xdiff = np.diff(Ŋ([x[0] for x in positions])) 82 zdiff = np.diff(Ŋ([x[2] for x in positions])) 83 assert xdiff[0] > 0 84 assert zdiff[0] == 0 85 nt.assert_allclose(xdiff, np.full_like(xdiff, xdiff[0]), rtol=0, atol=1e-10) 86 nt.assert_allclose(zdiff, np.full_like(zdiff, zdiff[0]), rtol=0, atol=1e-10) 87 strains_inc = np.zeros_like(timestamps) 88 for t, time in enumerate(timestamps[:-1], start=1): 89 strains_inc[t] = strains_inc[t - 1] + ( 90 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 91 ) 92 # fig, ax, _, _ = _vis.pathline_box2d( 93 # None, 94 # get_velocity, 95 # "xz", 96 # strains_inc, 97 # positions, 98 # ".", 99 # Ŋ([-2e5, -2e5]), 100 # Ŋ([2e5, 2e5]), 101 # [20, 20], 102 # ) 103 # fig.savefig("/tmp/fig.png") 104 nt.assert_allclose( 105 strains_inc, 106 (timestamps - timestamps[0]) * 1e-15, 107 atol=5e-15, 108 rtol=0, 109 )
Test for accumulating strain via strain increment calculations.
112class TestOlivineA: 113 """Tests for stationary A-type olivine polycrystals in 2D simple shear.""" 114 115 class_id = "olivineA" 116 117 @classmethod 118 def run( 119 cls, 120 params, 121 timestamps, 122 strain_rate, 123 get_velocity_gradient, 124 shear_direction, 125 seed=None, 126 return_fse=None, 127 get_position=lambda t: np.zeros(3), # Stationary particles by default. 128 ): 129 """Reusable logic for 2D olivine (A-type) simple shear tests. 130 131 Returns a tuple with the mineral and the FSE angle (or `None` if `return_fse` is 132 `None`). 133 134 """ 135 mineral = _minerals.Mineral( 136 phase=_core.MineralPhase.olivine, 137 fabric=_core.MineralFabric.olivine_A, 138 regime=_core.DeformationRegime.matrix_dislocation, 139 n_grains=params["number_of_grains"], 140 seed=seed, 141 ) 142 deformation_gradient = np.eye(3) # Undeformed initial state. 143 θ_fse = np.empty_like(timestamps) 144 θ_fse[0] = 45 145 146 for t, time in enumerate(timestamps[:-1], start=1): 147 # Set up logging message depending on dynamic parameter and seeds. 148 msg_start = ( 149 f"N = {params['number_of_grains']}; " 150 + f"λ∗ = {params['nucleation_efficiency']}; " 151 + f"X = {params['gbs_threshold']}; " 152 + f"M∗ = {params['gbm_mobility']}; " 153 ) 154 if seed is not None: 155 msg_start += f"# {seed}; " 156 157 _log.info(msg_start + "step %s/%s (t = %s)", t, len(timestamps) - 1, time) 158 159 deformation_gradient = mineral.update_orientations( 160 params, 161 deformation_gradient, 162 get_velocity_gradient, 163 pathline=(time, timestamps[t], get_position), 164 ) 165 _log.debug( 166 "› velocity gradient = %s", 167 get_velocity_gradient(np.nan, np.full(3, np.nan)).flatten(), 168 ) 169 _log.debug("› strain D₀t = %.2f", strain_rate * timestamps[t]) 170 _log.debug( 171 "› grain fractions: median = %s, max = %s, min = %s", 172 np.median(mineral.fractions[-1]), 173 np.max(mineral.fractions[-1]), 174 np.min(mineral.fractions[-1]), 175 ) 176 if return_fse: 177 _, fse_v = _diagnostics.finite_strain(deformation_gradient) 178 θ_fse[t] = _diagnostics.smallest_angle(fse_v, shear_direction) 179 else: 180 θ_fse = None 181 182 return mineral, θ_fse 183 184 @classmethod 185 def interp_GBM_Kaminski2001(cls, strains): 186 """Interpolate Kaminski & Ribe, 2001 data to get target angles at `strains`.""" 187 _log.info("interpolating target CPO angles...") 188 data = _io.read_scsv(_io.data("thirdparty") / "Kaminski2001_GBMshear.scsv") 189 cs_M0 = PchipInterpolator( 190 _utils.remove_nans(data.equivalent_strain_M0) / 200, 191 _utils.remove_nans(data.angle_M0), 192 ) 193 cs_M50 = PchipInterpolator( 194 _utils.remove_nans(data.equivalent_strain_M50) / 200, 195 _utils.remove_nans(data.angle_M50), 196 ) 197 cs_M200 = PchipInterpolator( 198 _utils.remove_nans(data.equivalent_strain_M200) / 200, 199 _utils.remove_nans(data.angle_M200), 200 ) 201 return [cs_M0(strains), cs_M50(strains), cs_M200(strains)] 202 203 @classmethod 204 def interp_GBM_FortranDRex(cls, strains): 205 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 206 _log.info("interpolating target CPO angles...") 207 data = _io.read_scsv(_io.data("drexF90") / "olA_D1E4_dt50_X0_L5.scsv") 208 data_strains = np.linspace(0, 1, 200) 209 cs_M0 = PchipInterpolator(data_strains, _utils.remove_nans(data.M0_angle)) 210 cs_M50 = PchipInterpolator(data_strains, _utils.remove_nans(data.M50_angle)) 211 cs_M200 = PchipInterpolator(data_strains, _utils.remove_nans(data.M200_angle)) 212 return [cs_M0(strains), cs_M50(strains), cs_M200(strains)] 213 214 @classmethod 215 def interp_GBS_FortranDRex(cls, strains): 216 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 217 _log.info("interpolating target CPO angles...") 218 data = _io.read_scsv(_io.data("thirdparty") / "a_axis_GBS_fortran.scsv") 219 data_strains = np.linspace(0, 1, 200) 220 cs_X0 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X0)) 221 cs_X20 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X20)) 222 cs_X40 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X40)) 223 return [cs_X0(strains), cs_X20(strains), cs_X40(strains)] 224 225 @classmethod 226 def interp_GBS_long_FortranDRex(cls, strains): 227 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 228 _log.info("interpolating target CPO angles...") 229 data = _io.read_scsv(_io.data("thirdparty") / "a_axis_GBS_long_fortran.scsv") 230 data_strains = np.linspace(0, 2.5, 500) 231 cs_X0 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X0)) 232 cs_X20 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X20)) 233 cs_X40 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X40)) 234 return [cs_X0(strains), cs_X20(strains), cs_X40(strains)] 235 236 @classmethod 237 def interp_GBS_Kaminski2004(cls, strains): 238 """Interpolate Kaminski & Ribe, 2001 data to get target angles at `strains`.""" 239 _log.info("interpolating target CPO angles...") 240 data = _io.read_scsv(_io.data("thirdparty") / "Kaminski2004_GBSshear.scsv") 241 cs_X0 = PchipInterpolator( 242 _utils.remove_nans(data.dimensionless_time_X0), 243 45 + _utils.remove_nans(data.angle_X0), 244 ) 245 cs_X0d2 = PchipInterpolator( 246 _utils.remove_nans(data.dimensionless_time_X0d2), 247 45 + _utils.remove_nans(data.angle_X0d2), 248 ) 249 cs_X0d4 = PchipInterpolator( 250 _utils.remove_nans(data.dimensionless_time_X0d4), 251 45 + _utils.remove_nans(data.angle_X0d4), 252 ) 253 return [cs_X0(strains), cs_X0d2(strains), cs_X0d4(strains)] 254 255 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 256 def test_zero_recrystallisation(self, seed): 257 """Check that M*=0 is a reliable switch to turn off recrystallisation.""" 258 params = _core.DefaultParams().as_dict() 259 params["gbm_mobility"] = 0 260 strain_rate = 1 261 timestamps = np.linspace(0, 1, 25) # Solve until D₀t=1 (tensorial strain). 262 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 263 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 264 mineral, _ = self.run( 265 params, 266 timestamps, 267 strain_rate, 268 get_velocity_gradient, 269 shear_direction, 270 seed=seed, 271 ) 272 for fractions in mineral.fractions[1:]: 273 nt.assert_allclose(fractions, mineral.fractions[0], atol=1e-15, rtol=0) 274 275 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 276 @pytest.mark.parametrize("gbm_mobility", [50, 100, 150]) 277 def test_grainsize_median(self, seed, gbm_mobility): 278 """Check that M*={50,100,150}, λ*=5 causes decreasing grain size median.""" 279 params = _core.DefaultParams().as_dict() 280 params["gbm_mobility"] = gbm_mobility 281 params["nucleation_efficiency"] = 5 282 strain_rate = 1 283 timestamps = np.linspace(0, 1, 25) # Solve until D₀t=1 (tensorial strain). 284 n_timestamps = len(timestamps) 285 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 286 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 287 mineral, _ = self.run( 288 params, 289 timestamps, 290 strain_rate, 291 get_velocity_gradient, 292 shear_direction, 293 seed=seed, 294 ) 295 medians = np.empty(n_timestamps) 296 for i, fractions in enumerate(mineral.fractions): 297 medians[i] = np.median(fractions) 298 299 # The first diff is positive (~1e-6) before nucleation sets in. 300 nt.assert_array_less(np.diff(medians)[1:], np.full(n_timestamps - 2, 0)) 301 302 @pytest.mark.slow 303 @pytest.mark.parametrize("gbs_threshold", [0, 0.2, 0.4]) 304 @pytest.mark.parametrize("nucleation_efficiency", [3, 5, 10]) 305 def test_dvdx_ensemble( 306 self, outdir, seeds_nearX45, ncpus, gbs_threshold, nucleation_efficiency 307 ): 308 r"""Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X). 309 310 Velocity gradient: 311 $$\bm{L} = \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix}$$ 312 313 """ 314 strain_rate = 1 315 timestamps = np.linspace(0, 1, 201) # Solve until D₀t=1 ('shear' γ=2). 316 n_timestamps = len(timestamps) 317 # Use `seeds` instead of `seeds_nearX45` if you have even more RAM and CPU time. 318 _seeds = seeds_nearX45 319 n_seeds = len(_seeds) 320 321 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 322 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 323 324 gbm_mobilities = [0, 50, 125, 200] 325 markers = ("x", "*", "d", "s") 326 327 _id = f"X{_io.stringify(gbs_threshold)}_L{_io.stringify(nucleation_efficiency)}" 328 # Output setup with optional logging and data series labels. 329 θ_fse = np.empty_like(timestamps) 330 angles = np.empty((len(gbm_mobilities), n_seeds, n_timestamps)) 331 optional_logging = cl.nullcontext() 332 if outdir is not None: 333 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_dvdx_ensemble_{_id}" 334 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 335 labels = [] 336 337 with optional_logging: 338 clock_start = process_time() 339 for m, gbm_mobility in enumerate(gbm_mobilities): 340 if m == 0: 341 return_fse = True 342 else: 343 return_fse = False 344 345 params = { 346 "phase_assemblage": (_core.MineralPhase.olivine,), 347 "phase_fractions": (1.0,), 348 "enstatite_fraction": 0.0, 349 "stress_exponent": 1.5, 350 "deformation_exponent": 3.5, 351 "gbm_mobility": gbm_mobility, 352 "gbs_threshold": gbs_threshold, 353 "nucleation_efficiency": nucleation_efficiency, 354 "number_of_grains": 5000, 355 "initial_olivine_fabric": "A", 356 } 357 358 _run = ft.partial( 359 self.run, 360 params, 361 timestamps, 362 strain_rate, 363 get_velocity_gradient, 364 shear_direction, 365 return_fse=return_fse, 366 ) 367 with Pool(processes=ncpus) as pool: 368 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 369 mineral, fse_angles = out 370 angles[m, s, :] = [ 371 _diagnostics.smallest_angle(v, shear_direction) 372 for v in _diagnostics.elasticity_components( 373 _minerals.voigt_averages([mineral], params) 374 )["hexagonal_axis"] 375 ] 376 # Save the whole mineral for the first seed only. 377 if outdir is not None and s == 0: 378 postfix = ( 379 f"M{_io.stringify(gbm_mobility)}" 380 + f"_X{_io.stringify(gbs_threshold)}" 381 + f"_L{_io.stringify(nucleation_efficiency)}" 382 ) 383 mineral.save(f"{out_basepath}.npz", postfix=postfix) 384 if return_fse: 385 θ_fse += fse_angles 386 387 if return_fse: 388 θ_fse /= n_seeds 389 390 if outdir is not None: 391 labels.append(f"$M^∗$ = {gbm_mobility}") 392 393 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 394 395 # Take ensemble means and optionally plot figure. 396 strains = timestamps * strain_rate 397 _log.info("postprocessing results for %s", _id) 398 result_angles = angles.mean(axis=1) 399 result_angles_err = angles.std(axis=1) 400 401 if outdir is not None: 402 schema = { 403 "delimiter": ",", 404 "missing": "-", 405 "fields": [ 406 { 407 "name": "strain", 408 "type": "integer", 409 "unit": "percent", 410 "fill": 999999, 411 } 412 ], 413 } 414 np.savez( 415 f"{out_basepath}.npz", 416 angles=result_angles, 417 angles_err=result_angles_err, 418 ) 419 _io.save_scsv( 420 f"{out_basepath}_strains.scsv", 421 schema, 422 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 423 ) 424 fig, ax, colors = _vis.alignment( 425 None, 426 strains, 427 result_angles, 428 markers, 429 labels, 430 err=result_angles_err, 431 θ_max=60, 432 θ_fse=θ_fse, 433 ) 434 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 435 436 @pytest.mark.slow 437 def test_dvdx_GBM(self, outdir, seeds_nearX45, ncpus): 438 r"""Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X). 439 440 Velocity gradient: 441 $$ 442 \bm{L} = 10^{-4} × 443 \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} 444 $$ 445 446 Results are compared to the Fortran 90 output. 447 448 """ 449 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 450 strain_rate = 1e-4 451 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 452 timestamps = np.linspace(0, 1e4, 51) # Solve until D₀t=1 ('shear' γ=2). 453 i_strain_40p = 10 # Index of 40% strain, lower strains are not relevant here. 454 i_strain_100p = 25 # Index of 100% strain, when M*=0 matches FSE. 455 params = _core.DefaultParams().as_dict() 456 params["gbs_threshold"] = 0 # No GBS, to match the Fortran parameters. 457 gbm_mobilities = (0, 10, 50, 125, 200) # Must be in ascending order. 458 markers = ("x", ".", "*", "d", "s") 459 # Use `seeds` instead of `seeds_nearX45` if you have even more RAM and CPU time. 460 _seeds = seeds_nearX45 461 n_seeds = len(_seeds) 462 angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps))) 463 θ_fse = np.zeros_like(timestamps) 464 strains = timestamps * strain_rate 465 M0_drexF90, M50_drexF90, M200_drexF90 = self.interp_GBM_FortranDRex(strains) 466 467 optional_logging = cl.nullcontext() 468 if outdir is not None: 469 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_mobility" 470 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 471 labels = [] 472 473 with optional_logging: 474 clock_start = process_time() 475 for m, gbm_mobility in enumerate(gbm_mobilities): 476 if m == 0: 477 return_fse = True 478 else: 479 return_fse = False 480 params["gbm_mobility"] = gbm_mobility 481 482 _run = ft.partial( 483 self.run, 484 params, 485 timestamps, 486 strain_rate, 487 get_velocity_gradient, 488 shear_direction, 489 return_fse=True, 490 ) 491 with Pool(processes=ncpus) as pool: 492 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 493 mineral, fse_angles = out 494 angles[m, s, :] = [ 495 _diagnostics.smallest_angle(v, shear_direction) 496 for v in _diagnostics.elasticity_components( 497 _minerals.voigt_averages([mineral], params) 498 )["hexagonal_axis"] 499 ] 500 # Save the whole mineral for the first seed only. 501 if outdir is not None and s == 0: 502 mineral.save( 503 f"{out_basepath}.npz", 504 postfix=f"M{_io.stringify(gbm_mobility)}", 505 ) 506 if return_fse: 507 θ_fse += fse_angles 508 509 if return_fse: 510 θ_fse /= n_seeds 511 512 if outdir is not None: 513 labels.append(f"$M^∗$ = {params['gbm_mobility']}") 514 515 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 516 517 # Take ensemble means and optionally plot figure. 518 _log.info("postprocessing results...") 519 result_angles = angles.mean(axis=1) 520 result_angles_err = angles.std(axis=1) 521 522 if outdir is not None: 523 schema = { 524 "delimiter": ",", 525 "missing": "-", 526 "fields": [ 527 { 528 "name": "strain", 529 "type": "integer", 530 "unit": "percent", 531 "fill": 999999, 532 } 533 ], 534 } 535 _io.save_scsv( 536 f"{out_basepath}_strains.scsv", 537 schema, 538 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 539 ) 540 np.savez( 541 f"{out_basepath}_angles.npz", 542 angles=result_angles, 543 err=result_angles_err, 544 ) 545 fig, ax, colors = _vis.alignment( 546 None, 547 strains, 548 result_angles, 549 markers, 550 labels, 551 err=result_angles_err, 552 θ_max=60, 553 θ_fse=θ_fse, 554 ) 555 ax.plot(strains, M0_drexF90, c=colors[0]) 556 ax.plot(strains, M50_drexF90, c=colors[2]) 557 ax.plot(strains, M200_drexF90, c=colors[4]) 558 _vis.show_Skemer2016_ShearStrainAngles( 559 ax, 560 ["Z&K 1200 C", "Z&K 1300 C"], 561 ["v", "^"], 562 ["k", "k"], 563 ["none", None], 564 [ 565 "Zhang & Karato, 1995\n(1473 K)", 566 "Zhang & Karato, 1995\n(1573 K)", 567 ], 568 _core.MineralFabric.olivine_A, 569 ) 570 # There is a lot of stuff on this legend, so put it outside the axes. 571 # These values might need to be tweaked depending on the font size, etc. 572 _legend = _utils.redraw_legend(ax, fig=fig, bbox_to_anchor=(1.66, 0.99)) 573 fig.savefig( 574 _io.resolve_path(f"{out_basepath}.pdf"), 575 bbox_extra_artists=(_legend,), 576 bbox_inches="tight", 577 ) 578 579 # Check that GBM speeds up the alignment between 40% and 100% strain. 580 _log.info("checking grain orientations...") 581 for i, θ in enumerate(result_angles[:-1], start=1): 582 nt.assert_array_less( 583 result_angles[i][i_strain_40p:i_strain_100p], 584 θ[i_strain_40p:i_strain_100p], 585 ) 586 587 # Check that M*=0 matches FSE (±1°) past 100% strain. 588 nt.assert_allclose( 589 result_angles[0][i_strain_100p:], 590 θ_fse[i_strain_100p:], 591 atol=1, 592 rtol=0, 593 ) 594 595 # Check that results match Fortran output past 40% strain. 596 nt.assert_allclose( 597 result_angles[0][i_strain_40p:], 598 M0_drexF90[i_strain_40p:], 599 atol=0, 600 rtol=0.1, # At 40% strain the match is worse than at higher strain. 601 ) 602 nt.assert_allclose( 603 result_angles[2][i_strain_40p:], 604 M50_drexF90[i_strain_40p:], 605 atol=1, 606 rtol=0, 607 ) 608 nt.assert_allclose( 609 result_angles[4][i_strain_40p:], 610 M200_drexF90[i_strain_40p:], 611 atol=1.5, 612 rtol=0, 613 ) 614 615 @pytest.mark.slow 616 def test_GBM_calibration(self, outdir, seeds, ncpus): 617 r"""Compare results for various values of $$M^∗$$ to A-type olivine data. 618 619 Velocity gradient: 620 $$ 621 \bm{L} = 10^{-4} × 622 \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} 623 $$ 624 625 Unlike `test_dvdx_GBM`, 626 grain boudary sliding is enabled here (see `_core.DefaultParams`). 627 Data are provided by [Skemer & Hansen, 2016](http://dx.doi.org/10.1016/j.tecto.2015.12.003). 628 629 """ 630 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 631 strain_rate = 1 632 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 633 timestamps = np.linspace(0, 3.2, 65) # Solve until D₀t=3.2 ('shear' γ=6.4). 634 params = _core.DefaultParams().as_dict() 635 params["number_of_grains"] = 5000 636 gbm_mobilities = (0, 10, 50, 125) # Must be in ascending order. 637 markers = ("x", "*", "1", ".") 638 # Uses 100 seeds by default; use all 1000 if you have more RAM and CPU time. 639 _seeds = seeds[:100] 640 n_seeds = len(_seeds) 641 angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps))) 642 θ_fse = np.zeros_like(timestamps) 643 strains = timestamps * strain_rate 644 645 optional_logging = cl.nullcontext() 646 if outdir is not None: 647 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_calibration" 648 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 649 labels = [] 650 651 with optional_logging: 652 clock_start = process_time() 653 for m, gbm_mobility in enumerate(gbm_mobilities): 654 return_fse = True if m == 0 else False 655 params["gbm_mobility"] = gbm_mobility 656 _run = ft.partial( 657 self.run, 658 params, 659 timestamps, 660 strain_rate, 661 get_velocity_gradient, 662 shear_direction, 663 return_fse=return_fse, 664 ) 665 with Pool(processes=ncpus) as pool: 666 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 667 mineral, fse_angles = out 668 angles[m, s, :] = [ 669 _diagnostics.smallest_angle(v, shear_direction) 670 for v in _diagnostics.elasticity_components( 671 _minerals.voigt_averages([mineral], params) 672 )["hexagonal_axis"] 673 ] 674 # Save the whole mineral for the first seed only. 675 if outdir is not None and s == 0: 676 mineral.save( 677 f"{out_basepath}.npz", 678 postfix=f"M{_io.stringify(gbm_mobility)}", 679 ) 680 if return_fse: 681 θ_fse += fse_angles 682 683 if return_fse: 684 θ_fse /= n_seeds 685 686 if outdir is not None: 687 labels.append(f"$M^∗$ = {params['gbm_mobility']}") 688 689 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 690 691 # Take ensemble means and optionally plot figure. 692 _log.info("postprocessing results...") 693 result_angles = angles.mean(axis=1) 694 result_angles_err = angles.std(axis=1) 695 696 if outdir is not None: 697 schema = { 698 "delimiter": ",", 699 "missing": "-", 700 "fields": [ 701 { 702 "name": "strain", 703 "type": "integer", 704 "unit": "percent", 705 "fill": 999999, 706 } 707 ], 708 } 709 _io.save_scsv( 710 f"{out_basepath}_strains.scsv", 711 schema, 712 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 713 ) 714 np.savez( 715 _io.resolve_path(f"{out_basepath}_ensemble_means.npz"), 716 angles=result_angles, 717 err=result_angles_err, 718 ) 719 fig = _vis.figure( 720 figsize=(_vis.DEFAULT_FIG_WIDTH * 3, _vis.DEFAULT_FIG_HEIGHT) 721 ) 722 fig, ax, colors = _vis.alignment( 723 fig.add_subplot(), 724 strains, 725 result_angles, 726 markers, 727 labels, 728 err=result_angles_err, 729 θ_max=80, 730 θ_fse=θ_fse, 731 ) 732 ( 733 _, 734 _, 735 _, 736 data_Skemer2016, 737 indices, 738 ) = _vis.show_Skemer2016_ShearStrainAngles( 739 ax, 740 [ 741 "Z&K 1200 C", 742 "Z&K 1300 C", 743 "Skemer 2011", 744 "Hansen 2014", 745 "Warren 2008", 746 "Webber 2010", 747 "H&W 2015", 748 ], 749 ["v", "^", "o", "s", "v", "o", "s"], 750 ["k", "k", "k", "k", "k", "k", "k"], 751 ["none", "none", "none", "none", None, None, None], 752 [ 753 "Zhang & Karato, 1995 (1473 K)", 754 "Zhang & Karato, 1995 (1573 K)", 755 "Skemer et al., 2011 (1500 K)", 756 "Hansen et al., 2014 (1473 K)", 757 "Warren et al., 2008", 758 "Webber et al., 2010", 759 "Hansen & Warren, 2015", 760 ], 761 fabric=_core.MineralFabric.olivine_A, 762 ) 763 _legend = _utils.redraw_legend(ax, loc="upper right", ncols=3) 764 fig.savefig( 765 _io.resolve_path(f"{out_basepath}.pdf"), 766 bbox_extra_artists=(_legend,), 767 bbox_inches="tight", 768 ) 769 r2vals = [] 770 for angles in result_angles: 771 _angles = PchipInterpolator(strains, angles) 772 r2 = np.sum( 773 [ 774 (a - b) ** 2 775 for a, b in zip( 776 _angles( 777 np.take(data_Skemer2016.shear_strain, indices) / 200 778 ), 779 np.take(data_Skemer2016.angle, indices), 780 ) 781 ] 782 ) 783 r2vals.append(r2) 784 _log.info( 785 "Sums of squared residuals (r-values) for each M∗: %s", r2vals 786 ) 787 788 @pytest.mark.big 789 def test_dudz_pathline(self, outdir, seed): 790 """Test alignment of olivine a-axis for a polycrystal advected on a pathline.""" 791 test_id = "dudz_pathline" 792 optional_logging = cl.nullcontext() 793 if outdir is not None: 794 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}" 795 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 796 797 with optional_logging: 798 shear_direction = Ŋ([1, 0, 0], dtype=np.float64) 799 strain_rate = 1e-15 # Moderate, realistic shear in the upper mantle. 800 get_velocity, get_velocity_gradient = _velocity.simple_shear_2d( 801 "X", "Z", strain_rate 802 ) 803 n_timesteps = 10 804 timestamps, get_position = _paths.get_pathline( 805 Ŋ([1e5, 0e0, 1e5]), 806 get_velocity, 807 get_velocity_gradient, 808 Ŋ([-2e5, 0e0, -2e5]), 809 Ŋ([2e5, 0e0, 2e5]), 810 2, 811 regular_steps=n_timesteps, 812 ) 813 positions = [get_position(t) for t in timestamps] 814 velocity_gradients = [ 815 get_velocity_gradient(np.nan, Ŋ(x)) for x in positions 816 ] 817 818 params = _core.DefaultParams().as_dict() 819 params["gbm_mobility"] = 10 820 params["number_of_grains"] = 5000 821 olA = _minerals.Mineral(n_grains=params["number_of_grains"], seed=seed) 822 deformation_gradient = np.eye(3) 823 strains = np.zeros_like(timestamps) 824 for t, time in enumerate(timestamps[:-1], start=1): 825 strains[t] = strains[t - 1] + ( 826 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 827 ) 828 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 829 deformation_gradient = olA.update_orientations( 830 params, 831 deformation_gradient, 832 get_velocity_gradient, 833 pathline=(time, timestamps[t], get_position), 834 ) 835 836 if outdir is not None: 837 olA.save(f"{out_basepath}.npz") 838 839 orient_resampled, fractions_resampled = _stats.resample_orientations( 840 olA.orientations, olA.fractions, seed=seed 841 ) 842 # About 36GB, 26 min needed with float64. GitHub macos runner has 14GB. 843 misorient_indices = _diagnostics.misorientation_indices( 844 orient_resampled, 845 _geo.LatticeSystem.orthorhombic, 846 ncpus=3, 847 ) 848 cpo_vectors = np.zeros((n_timesteps + 1, 3)) 849 cpo_angles = np.zeros(n_timesteps + 1) 850 for i, matrices in enumerate(orient_resampled): 851 cpo_vectors[i] = _diagnostics.bingham_average( 852 matrices, 853 axis=_minerals.OLIVINE_PRIMARY_AXIS[olA.fabric], 854 ) 855 cpo_angles[i] = _diagnostics.smallest_angle( 856 cpo_vectors[i], shear_direction 857 ) 858 859 # Check for mostly decreasing CPO angles (exclude initial condition). 860 _log.debug("cpo angles: %s", cpo_angles) 861 nt.assert_array_less(np.diff(cpo_angles[1:]), np.ones(n_timesteps - 1)) 862 # Check for increasing CPO strength (M-index). 863 _log.debug("cpo strengths: %s", misorient_indices) 864 nt.assert_array_less( 865 np.full(n_timesteps, -0.01), np.diff(misorient_indices) 866 ) 867 # Check that last angle is <5° (M*=125) or <10° (M*=10). 868 assert cpo_angles[-1] < 10 869 870 if outdir is not None: 871 fig, ax, _, _ = _vis.steady_box2d( 872 None, 873 (get_velocity, [20, 20]), 874 (positions, Ŋ([-2e5, -2e5]), Ŋ([2e5, 2e5])), 875 "xz", 876 (cpo_vectors, misorient_indices), 877 strains, 878 ) 879 fig.savefig(f"{out_basepath}.pdf")
Tests for stationary A-type olivine polycrystals in 2D simple shear.
117 @classmethod 118 def run( 119 cls, 120 params, 121 timestamps, 122 strain_rate, 123 get_velocity_gradient, 124 shear_direction, 125 seed=None, 126 return_fse=None, 127 get_position=lambda t: np.zeros(3), # Stationary particles by default. 128 ): 129 """Reusable logic for 2D olivine (A-type) simple shear tests. 130 131 Returns a tuple with the mineral and the FSE angle (or `None` if `return_fse` is 132 `None`). 133 134 """ 135 mineral = _minerals.Mineral( 136 phase=_core.MineralPhase.olivine, 137 fabric=_core.MineralFabric.olivine_A, 138 regime=_core.DeformationRegime.matrix_dislocation, 139 n_grains=params["number_of_grains"], 140 seed=seed, 141 ) 142 deformation_gradient = np.eye(3) # Undeformed initial state. 143 θ_fse = np.empty_like(timestamps) 144 θ_fse[0] = 45 145 146 for t, time in enumerate(timestamps[:-1], start=1): 147 # Set up logging message depending on dynamic parameter and seeds. 148 msg_start = ( 149 f"N = {params['number_of_grains']}; " 150 + f"λ∗ = {params['nucleation_efficiency']}; " 151 + f"X = {params['gbs_threshold']}; " 152 + f"M∗ = {params['gbm_mobility']}; " 153 ) 154 if seed is not None: 155 msg_start += f"# {seed}; " 156 157 _log.info(msg_start + "step %s/%s (t = %s)", t, len(timestamps) - 1, time) 158 159 deformation_gradient = mineral.update_orientations( 160 params, 161 deformation_gradient, 162 get_velocity_gradient, 163 pathline=(time, timestamps[t], get_position), 164 ) 165 _log.debug( 166 "› velocity gradient = %s", 167 get_velocity_gradient(np.nan, np.full(3, np.nan)).flatten(), 168 ) 169 _log.debug("› strain D₀t = %.2f", strain_rate * timestamps[t]) 170 _log.debug( 171 "› grain fractions: median = %s, max = %s, min = %s", 172 np.median(mineral.fractions[-1]), 173 np.max(mineral.fractions[-1]), 174 np.min(mineral.fractions[-1]), 175 ) 176 if return_fse: 177 _, fse_v = _diagnostics.finite_strain(deformation_gradient) 178 θ_fse[t] = _diagnostics.smallest_angle(fse_v, shear_direction) 179 else: 180 θ_fse = None 181 182 return mineral, θ_fse
Reusable logic for 2D olivine (A-type) simple shear tests.
Returns a tuple with the mineral and the FSE angle (or None
if return_fse
is
None
).
184 @classmethod 185 def interp_GBM_Kaminski2001(cls, strains): 186 """Interpolate Kaminski & Ribe, 2001 data to get target angles at `strains`.""" 187 _log.info("interpolating target CPO angles...") 188 data = _io.read_scsv(_io.data("thirdparty") / "Kaminski2001_GBMshear.scsv") 189 cs_M0 = PchipInterpolator( 190 _utils.remove_nans(data.equivalent_strain_M0) / 200, 191 _utils.remove_nans(data.angle_M0), 192 ) 193 cs_M50 = PchipInterpolator( 194 _utils.remove_nans(data.equivalent_strain_M50) / 200, 195 _utils.remove_nans(data.angle_M50), 196 ) 197 cs_M200 = PchipInterpolator( 198 _utils.remove_nans(data.equivalent_strain_M200) / 200, 199 _utils.remove_nans(data.angle_M200), 200 ) 201 return [cs_M0(strains), cs_M50(strains), cs_M200(strains)]
Interpolate Kaminski & Ribe, 2001 data to get target angles at strains
.
203 @classmethod 204 def interp_GBM_FortranDRex(cls, strains): 205 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 206 _log.info("interpolating target CPO angles...") 207 data = _io.read_scsv(_io.data("drexF90") / "olA_D1E4_dt50_X0_L5.scsv") 208 data_strains = np.linspace(0, 1, 200) 209 cs_M0 = PchipInterpolator(data_strains, _utils.remove_nans(data.M0_angle)) 210 cs_M50 = PchipInterpolator(data_strains, _utils.remove_nans(data.M50_angle)) 211 cs_M200 = PchipInterpolator(data_strains, _utils.remove_nans(data.M200_angle)) 212 return [cs_M0(strains), cs_M50(strains), cs_M200(strains)]
Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.
214 @classmethod 215 def interp_GBS_FortranDRex(cls, strains): 216 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 217 _log.info("interpolating target CPO angles...") 218 data = _io.read_scsv(_io.data("thirdparty") / "a_axis_GBS_fortran.scsv") 219 data_strains = np.linspace(0, 1, 200) 220 cs_X0 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X0)) 221 cs_X20 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X20)) 222 cs_X40 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X40)) 223 return [cs_X0(strains), cs_X20(strains), cs_X40(strains)]
Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.
225 @classmethod 226 def interp_GBS_long_FortranDRex(cls, strains): 227 """Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.""" 228 _log.info("interpolating target CPO angles...") 229 data = _io.read_scsv(_io.data("thirdparty") / "a_axis_GBS_long_fortran.scsv") 230 data_strains = np.linspace(0, 2.5, 500) 231 cs_X0 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X0)) 232 cs_X20 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X20)) 233 cs_X40 = PchipInterpolator(data_strains, _utils.remove_nans(data.a_mean_X40)) 234 return [cs_X0(strains), cs_X20(strains), cs_X40(strains)]
Interpolate angles produced using 'tools/drex_forward_simpleshear.f90'.
236 @classmethod 237 def interp_GBS_Kaminski2004(cls, strains): 238 """Interpolate Kaminski & Ribe, 2001 data to get target angles at `strains`.""" 239 _log.info("interpolating target CPO angles...") 240 data = _io.read_scsv(_io.data("thirdparty") / "Kaminski2004_GBSshear.scsv") 241 cs_X0 = PchipInterpolator( 242 _utils.remove_nans(data.dimensionless_time_X0), 243 45 + _utils.remove_nans(data.angle_X0), 244 ) 245 cs_X0d2 = PchipInterpolator( 246 _utils.remove_nans(data.dimensionless_time_X0d2), 247 45 + _utils.remove_nans(data.angle_X0d2), 248 ) 249 cs_X0d4 = PchipInterpolator( 250 _utils.remove_nans(data.dimensionless_time_X0d4), 251 45 + _utils.remove_nans(data.angle_X0d4), 252 ) 253 return [cs_X0(strains), cs_X0d2(strains), cs_X0d4(strains)]
Interpolate Kaminski & Ribe, 2001 data to get target angles at strains
.
255 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 256 def test_zero_recrystallisation(self, seed): 257 """Check that M*=0 is a reliable switch to turn off recrystallisation.""" 258 params = _core.DefaultParams().as_dict() 259 params["gbm_mobility"] = 0 260 strain_rate = 1 261 timestamps = np.linspace(0, 1, 25) # Solve until D₀t=1 (tensorial strain). 262 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 263 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 264 mineral, _ = self.run( 265 params, 266 timestamps, 267 strain_rate, 268 get_velocity_gradient, 269 shear_direction, 270 seed=seed, 271 ) 272 for fractions in mineral.fractions[1:]: 273 nt.assert_allclose(fractions, mineral.fractions[0], atol=1e-15, rtol=0)
Check that M*=0 is a reliable switch to turn off recrystallisation.
275 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 276 @pytest.mark.parametrize("gbm_mobility", [50, 100, 150]) 277 def test_grainsize_median(self, seed, gbm_mobility): 278 """Check that M*={50,100,150}, λ*=5 causes decreasing grain size median.""" 279 params = _core.DefaultParams().as_dict() 280 params["gbm_mobility"] = gbm_mobility 281 params["nucleation_efficiency"] = 5 282 strain_rate = 1 283 timestamps = np.linspace(0, 1, 25) # Solve until D₀t=1 (tensorial strain). 284 n_timestamps = len(timestamps) 285 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 286 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 287 mineral, _ = self.run( 288 params, 289 timestamps, 290 strain_rate, 291 get_velocity_gradient, 292 shear_direction, 293 seed=seed, 294 ) 295 medians = np.empty(n_timestamps) 296 for i, fractions in enumerate(mineral.fractions): 297 medians[i] = np.median(fractions) 298 299 # The first diff is positive (~1e-6) before nucleation sets in. 300 nt.assert_array_less(np.diff(medians)[1:], np.full(n_timestamps - 2, 0))
Check that M={50,100,150}, λ=5 causes decreasing grain size median.
302 @pytest.mark.slow 303 @pytest.mark.parametrize("gbs_threshold", [0, 0.2, 0.4]) 304 @pytest.mark.parametrize("nucleation_efficiency", [3, 5, 10]) 305 def test_dvdx_ensemble( 306 self, outdir, seeds_nearX45, ncpus, gbs_threshold, nucleation_efficiency 307 ): 308 r"""Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X). 309 310 Velocity gradient: 311 $$\bm{L} = \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix}$$ 312 313 """ 314 strain_rate = 1 315 timestamps = np.linspace(0, 1, 201) # Solve until D₀t=1 ('shear' γ=2). 316 n_timestamps = len(timestamps) 317 # Use `seeds` instead of `seeds_nearX45` if you have even more RAM and CPU time. 318 _seeds = seeds_nearX45 319 n_seeds = len(_seeds) 320 321 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 322 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 323 324 gbm_mobilities = [0, 50, 125, 200] 325 markers = ("x", "*", "d", "s") 326 327 _id = f"X{_io.stringify(gbs_threshold)}_L{_io.stringify(nucleation_efficiency)}" 328 # Output setup with optional logging and data series labels. 329 θ_fse = np.empty_like(timestamps) 330 angles = np.empty((len(gbm_mobilities), n_seeds, n_timestamps)) 331 optional_logging = cl.nullcontext() 332 if outdir is not None: 333 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_dvdx_ensemble_{_id}" 334 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 335 labels = [] 336 337 with optional_logging: 338 clock_start = process_time() 339 for m, gbm_mobility in enumerate(gbm_mobilities): 340 if m == 0: 341 return_fse = True 342 else: 343 return_fse = False 344 345 params = { 346 "phase_assemblage": (_core.MineralPhase.olivine,), 347 "phase_fractions": (1.0,), 348 "enstatite_fraction": 0.0, 349 "stress_exponent": 1.5, 350 "deformation_exponent": 3.5, 351 "gbm_mobility": gbm_mobility, 352 "gbs_threshold": gbs_threshold, 353 "nucleation_efficiency": nucleation_efficiency, 354 "number_of_grains": 5000, 355 "initial_olivine_fabric": "A", 356 } 357 358 _run = ft.partial( 359 self.run, 360 params, 361 timestamps, 362 strain_rate, 363 get_velocity_gradient, 364 shear_direction, 365 return_fse=return_fse, 366 ) 367 with Pool(processes=ncpus) as pool: 368 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 369 mineral, fse_angles = out 370 angles[m, s, :] = [ 371 _diagnostics.smallest_angle(v, shear_direction) 372 for v in _diagnostics.elasticity_components( 373 _minerals.voigt_averages([mineral], params) 374 )["hexagonal_axis"] 375 ] 376 # Save the whole mineral for the first seed only. 377 if outdir is not None and s == 0: 378 postfix = ( 379 f"M{_io.stringify(gbm_mobility)}" 380 + f"_X{_io.stringify(gbs_threshold)}" 381 + f"_L{_io.stringify(nucleation_efficiency)}" 382 ) 383 mineral.save(f"{out_basepath}.npz", postfix=postfix) 384 if return_fse: 385 θ_fse += fse_angles 386 387 if return_fse: 388 θ_fse /= n_seeds 389 390 if outdir is not None: 391 labels.append(f"$M^∗$ = {gbm_mobility}") 392 393 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 394 395 # Take ensemble means and optionally plot figure. 396 strains = timestamps * strain_rate 397 _log.info("postprocessing results for %s", _id) 398 result_angles = angles.mean(axis=1) 399 result_angles_err = angles.std(axis=1) 400 401 if outdir is not None: 402 schema = { 403 "delimiter": ",", 404 "missing": "-", 405 "fields": [ 406 { 407 "name": "strain", 408 "type": "integer", 409 "unit": "percent", 410 "fill": 999999, 411 } 412 ], 413 } 414 np.savez( 415 f"{out_basepath}.npz", 416 angles=result_angles, 417 angles_err=result_angles_err, 418 ) 419 _io.save_scsv( 420 f"{out_basepath}_strains.scsv", 421 schema, 422 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 423 ) 424 fig, ax, colors = _vis.alignment( 425 None, 426 strains, 427 result_angles, 428 markers, 429 labels, 430 err=result_angles_err, 431 θ_max=60, 432 θ_fse=θ_fse, 433 ) 434 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf"))
Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X).
Velocity gradient: $$\bm{L} = \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix}$$
436 @pytest.mark.slow 437 def test_dvdx_GBM(self, outdir, seeds_nearX45, ncpus): 438 r"""Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X). 439 440 Velocity gradient: 441 $$ 442 \bm{L} = 10^{-4} × 443 \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} 444 $$ 445 446 Results are compared to the Fortran 90 output. 447 448 """ 449 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 450 strain_rate = 1e-4 451 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 452 timestamps = np.linspace(0, 1e4, 51) # Solve until D₀t=1 ('shear' γ=2). 453 i_strain_40p = 10 # Index of 40% strain, lower strains are not relevant here. 454 i_strain_100p = 25 # Index of 100% strain, when M*=0 matches FSE. 455 params = _core.DefaultParams().as_dict() 456 params["gbs_threshold"] = 0 # No GBS, to match the Fortran parameters. 457 gbm_mobilities = (0, 10, 50, 125, 200) # Must be in ascending order. 458 markers = ("x", ".", "*", "d", "s") 459 # Use `seeds` instead of `seeds_nearX45` if you have even more RAM and CPU time. 460 _seeds = seeds_nearX45 461 n_seeds = len(_seeds) 462 angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps))) 463 θ_fse = np.zeros_like(timestamps) 464 strains = timestamps * strain_rate 465 M0_drexF90, M50_drexF90, M200_drexF90 = self.interp_GBM_FortranDRex(strains) 466 467 optional_logging = cl.nullcontext() 468 if outdir is not None: 469 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_mobility" 470 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 471 labels = [] 472 473 with optional_logging: 474 clock_start = process_time() 475 for m, gbm_mobility in enumerate(gbm_mobilities): 476 if m == 0: 477 return_fse = True 478 else: 479 return_fse = False 480 params["gbm_mobility"] = gbm_mobility 481 482 _run = ft.partial( 483 self.run, 484 params, 485 timestamps, 486 strain_rate, 487 get_velocity_gradient, 488 shear_direction, 489 return_fse=True, 490 ) 491 with Pool(processes=ncpus) as pool: 492 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 493 mineral, fse_angles = out 494 angles[m, s, :] = [ 495 _diagnostics.smallest_angle(v, shear_direction) 496 for v in _diagnostics.elasticity_components( 497 _minerals.voigt_averages([mineral], params) 498 )["hexagonal_axis"] 499 ] 500 # Save the whole mineral for the first seed only. 501 if outdir is not None and s == 0: 502 mineral.save( 503 f"{out_basepath}.npz", 504 postfix=f"M{_io.stringify(gbm_mobility)}", 505 ) 506 if return_fse: 507 θ_fse += fse_angles 508 509 if return_fse: 510 θ_fse /= n_seeds 511 512 if outdir is not None: 513 labels.append(f"$M^∗$ = {params['gbm_mobility']}") 514 515 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 516 517 # Take ensemble means and optionally plot figure. 518 _log.info("postprocessing results...") 519 result_angles = angles.mean(axis=1) 520 result_angles_err = angles.std(axis=1) 521 522 if outdir is not None: 523 schema = { 524 "delimiter": ",", 525 "missing": "-", 526 "fields": [ 527 { 528 "name": "strain", 529 "type": "integer", 530 "unit": "percent", 531 "fill": 999999, 532 } 533 ], 534 } 535 _io.save_scsv( 536 f"{out_basepath}_strains.scsv", 537 schema, 538 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 539 ) 540 np.savez( 541 f"{out_basepath}_angles.npz", 542 angles=result_angles, 543 err=result_angles_err, 544 ) 545 fig, ax, colors = _vis.alignment( 546 None, 547 strains, 548 result_angles, 549 markers, 550 labels, 551 err=result_angles_err, 552 θ_max=60, 553 θ_fse=θ_fse, 554 ) 555 ax.plot(strains, M0_drexF90, c=colors[0]) 556 ax.plot(strains, M50_drexF90, c=colors[2]) 557 ax.plot(strains, M200_drexF90, c=colors[4]) 558 _vis.show_Skemer2016_ShearStrainAngles( 559 ax, 560 ["Z&K 1200 C", "Z&K 1300 C"], 561 ["v", "^"], 562 ["k", "k"], 563 ["none", None], 564 [ 565 "Zhang & Karato, 1995\n(1473 K)", 566 "Zhang & Karato, 1995\n(1573 K)", 567 ], 568 _core.MineralFabric.olivine_A, 569 ) 570 # There is a lot of stuff on this legend, so put it outside the axes. 571 # These values might need to be tweaked depending on the font size, etc. 572 _legend = _utils.redraw_legend(ax, fig=fig, bbox_to_anchor=(1.66, 0.99)) 573 fig.savefig( 574 _io.resolve_path(f"{out_basepath}.pdf"), 575 bbox_extra_artists=(_legend,), 576 bbox_inches="tight", 577 ) 578 579 # Check that GBM speeds up the alignment between 40% and 100% strain. 580 _log.info("checking grain orientations...") 581 for i, θ in enumerate(result_angles[:-1], start=1): 582 nt.assert_array_less( 583 result_angles[i][i_strain_40p:i_strain_100p], 584 θ[i_strain_40p:i_strain_100p], 585 ) 586 587 # Check that M*=0 matches FSE (±1°) past 100% strain. 588 nt.assert_allclose( 589 result_angles[0][i_strain_100p:], 590 θ_fse[i_strain_100p:], 591 atol=1, 592 rtol=0, 593 ) 594 595 # Check that results match Fortran output past 40% strain. 596 nt.assert_allclose( 597 result_angles[0][i_strain_40p:], 598 M0_drexF90[i_strain_40p:], 599 atol=0, 600 rtol=0.1, # At 40% strain the match is worse than at higher strain. 601 ) 602 nt.assert_allclose( 603 result_angles[2][i_strain_40p:], 604 M50_drexF90[i_strain_40p:], 605 atol=1, 606 rtol=0, 607 ) 608 nt.assert_allclose( 609 result_angles[4][i_strain_40p:], 610 M200_drexF90[i_strain_40p:], 611 atol=1.5, 612 rtol=0, 613 )
Test a-axis alignment to shear in Y direction (init. SCCS near 45° from X).
Velocity gradient: $$ \bm{L} = 10^{-4} × \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} $$
Results are compared to the Fortran 90 output.
615 @pytest.mark.slow 616 def test_GBM_calibration(self, outdir, seeds, ncpus): 617 r"""Compare results for various values of $$M^∗$$ to A-type olivine data. 618 619 Velocity gradient: 620 $$ 621 \bm{L} = 10^{-4} × 622 \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} 623 $$ 624 625 Unlike `test_dvdx_GBM`, 626 grain boudary sliding is enabled here (see `_core.DefaultParams`). 627 Data are provided by [Skemer & Hansen, 2016](http://dx.doi.org/10.1016/j.tecto.2015.12.003). 628 629 """ 630 shear_direction = Ŋ([0, 1, 0], dtype=np.float64) 631 strain_rate = 1 632 _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate) 633 timestamps = np.linspace(0, 3.2, 65) # Solve until D₀t=3.2 ('shear' γ=6.4). 634 params = _core.DefaultParams().as_dict() 635 params["number_of_grains"] = 5000 636 gbm_mobilities = (0, 10, 50, 125) # Must be in ascending order. 637 markers = ("x", "*", "1", ".") 638 # Uses 100 seeds by default; use all 1000 if you have more RAM and CPU time. 639 _seeds = seeds[:100] 640 n_seeds = len(_seeds) 641 angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps))) 642 θ_fse = np.zeros_like(timestamps) 643 strains = timestamps * strain_rate 644 645 optional_logging = cl.nullcontext() 646 if outdir is not None: 647 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_calibration" 648 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 649 labels = [] 650 651 with optional_logging: 652 clock_start = process_time() 653 for m, gbm_mobility in enumerate(gbm_mobilities): 654 return_fse = True if m == 0 else False 655 params["gbm_mobility"] = gbm_mobility 656 _run = ft.partial( 657 self.run, 658 params, 659 timestamps, 660 strain_rate, 661 get_velocity_gradient, 662 shear_direction, 663 return_fse=return_fse, 664 ) 665 with Pool(processes=ncpus) as pool: 666 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 667 mineral, fse_angles = out 668 angles[m, s, :] = [ 669 _diagnostics.smallest_angle(v, shear_direction) 670 for v in _diagnostics.elasticity_components( 671 _minerals.voigt_averages([mineral], params) 672 )["hexagonal_axis"] 673 ] 674 # Save the whole mineral for the first seed only. 675 if outdir is not None and s == 0: 676 mineral.save( 677 f"{out_basepath}.npz", 678 postfix=f"M{_io.stringify(gbm_mobility)}", 679 ) 680 if return_fse: 681 θ_fse += fse_angles 682 683 if return_fse: 684 θ_fse /= n_seeds 685 686 if outdir is not None: 687 labels.append(f"$M^∗$ = {params['gbm_mobility']}") 688 689 _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start)) 690 691 # Take ensemble means and optionally plot figure. 692 _log.info("postprocessing results...") 693 result_angles = angles.mean(axis=1) 694 result_angles_err = angles.std(axis=1) 695 696 if outdir is not None: 697 schema = { 698 "delimiter": ",", 699 "missing": "-", 700 "fields": [ 701 { 702 "name": "strain", 703 "type": "integer", 704 "unit": "percent", 705 "fill": 999999, 706 } 707 ], 708 } 709 _io.save_scsv( 710 f"{out_basepath}_strains.scsv", 711 schema, 712 [[int(D * 200) for D in strains]], # Shear strain % is 200 * D₀. 713 ) 714 np.savez( 715 _io.resolve_path(f"{out_basepath}_ensemble_means.npz"), 716 angles=result_angles, 717 err=result_angles_err, 718 ) 719 fig = _vis.figure( 720 figsize=(_vis.DEFAULT_FIG_WIDTH * 3, _vis.DEFAULT_FIG_HEIGHT) 721 ) 722 fig, ax, colors = _vis.alignment( 723 fig.add_subplot(), 724 strains, 725 result_angles, 726 markers, 727 labels, 728 err=result_angles_err, 729 θ_max=80, 730 θ_fse=θ_fse, 731 ) 732 ( 733 _, 734 _, 735 _, 736 data_Skemer2016, 737 indices, 738 ) = _vis.show_Skemer2016_ShearStrainAngles( 739 ax, 740 [ 741 "Z&K 1200 C", 742 "Z&K 1300 C", 743 "Skemer 2011", 744 "Hansen 2014", 745 "Warren 2008", 746 "Webber 2010", 747 "H&W 2015", 748 ], 749 ["v", "^", "o", "s", "v", "o", "s"], 750 ["k", "k", "k", "k", "k", "k", "k"], 751 ["none", "none", "none", "none", None, None, None], 752 [ 753 "Zhang & Karato, 1995 (1473 K)", 754 "Zhang & Karato, 1995 (1573 K)", 755 "Skemer et al., 2011 (1500 K)", 756 "Hansen et al., 2014 (1473 K)", 757 "Warren et al., 2008", 758 "Webber et al., 2010", 759 "Hansen & Warren, 2015", 760 ], 761 fabric=_core.MineralFabric.olivine_A, 762 ) 763 _legend = _utils.redraw_legend(ax, loc="upper right", ncols=3) 764 fig.savefig( 765 _io.resolve_path(f"{out_basepath}.pdf"), 766 bbox_extra_artists=(_legend,), 767 bbox_inches="tight", 768 ) 769 r2vals = [] 770 for angles in result_angles: 771 _angles = PchipInterpolator(strains, angles) 772 r2 = np.sum( 773 [ 774 (a - b) ** 2 775 for a, b in zip( 776 _angles( 777 np.take(data_Skemer2016.shear_strain, indices) / 200 778 ), 779 np.take(data_Skemer2016.angle, indices), 780 ) 781 ] 782 ) 783 r2vals.append(r2) 784 _log.info( 785 "Sums of squared residuals (r-values) for each M∗: %s", r2vals 786 )
Compare results for various values of $$M^∗$$ to A-type olivine data.
Velocity gradient: $$ \bm{L} = 10^{-4} × \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix} $$
Unlike test_dvdx_GBM
,
grain boudary sliding is enabled here (see _core.DefaultParams
).
Data are provided by Skemer & Hansen, 2016.
788 @pytest.mark.big 789 def test_dudz_pathline(self, outdir, seed): 790 """Test alignment of olivine a-axis for a polycrystal advected on a pathline.""" 791 test_id = "dudz_pathline" 792 optional_logging = cl.nullcontext() 793 if outdir is not None: 794 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}" 795 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 796 797 with optional_logging: 798 shear_direction = Ŋ([1, 0, 0], dtype=np.float64) 799 strain_rate = 1e-15 # Moderate, realistic shear in the upper mantle. 800 get_velocity, get_velocity_gradient = _velocity.simple_shear_2d( 801 "X", "Z", strain_rate 802 ) 803 n_timesteps = 10 804 timestamps, get_position = _paths.get_pathline( 805 Ŋ([1e5, 0e0, 1e5]), 806 get_velocity, 807 get_velocity_gradient, 808 Ŋ([-2e5, 0e0, -2e5]), 809 Ŋ([2e5, 0e0, 2e5]), 810 2, 811 regular_steps=n_timesteps, 812 ) 813 positions = [get_position(t) for t in timestamps] 814 velocity_gradients = [ 815 get_velocity_gradient(np.nan, Ŋ(x)) for x in positions 816 ] 817 818 params = _core.DefaultParams().as_dict() 819 params["gbm_mobility"] = 10 820 params["number_of_grains"] = 5000 821 olA = _minerals.Mineral(n_grains=params["number_of_grains"], seed=seed) 822 deformation_gradient = np.eye(3) 823 strains = np.zeros_like(timestamps) 824 for t, time in enumerate(timestamps[:-1], start=1): 825 strains[t] = strains[t - 1] + ( 826 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 827 ) 828 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 829 deformation_gradient = olA.update_orientations( 830 params, 831 deformation_gradient, 832 get_velocity_gradient, 833 pathline=(time, timestamps[t], get_position), 834 ) 835 836 if outdir is not None: 837 olA.save(f"{out_basepath}.npz") 838 839 orient_resampled, fractions_resampled = _stats.resample_orientations( 840 olA.orientations, olA.fractions, seed=seed 841 ) 842 # About 36GB, 26 min needed with float64. GitHub macos runner has 14GB. 843 misorient_indices = _diagnostics.misorientation_indices( 844 orient_resampled, 845 _geo.LatticeSystem.orthorhombic, 846 ncpus=3, 847 ) 848 cpo_vectors = np.zeros((n_timesteps + 1, 3)) 849 cpo_angles = np.zeros(n_timesteps + 1) 850 for i, matrices in enumerate(orient_resampled): 851 cpo_vectors[i] = _diagnostics.bingham_average( 852 matrices, 853 axis=_minerals.OLIVINE_PRIMARY_AXIS[olA.fabric], 854 ) 855 cpo_angles[i] = _diagnostics.smallest_angle( 856 cpo_vectors[i], shear_direction 857 ) 858 859 # Check for mostly decreasing CPO angles (exclude initial condition). 860 _log.debug("cpo angles: %s", cpo_angles) 861 nt.assert_array_less(np.diff(cpo_angles[1:]), np.ones(n_timesteps - 1)) 862 # Check for increasing CPO strength (M-index). 863 _log.debug("cpo strengths: %s", misorient_indices) 864 nt.assert_array_less( 865 np.full(n_timesteps, -0.01), np.diff(misorient_indices) 866 ) 867 # Check that last angle is <5° (M*=125) or <10° (M*=10). 868 assert cpo_angles[-1] < 10 869 870 if outdir is not None: 871 fig, ax, _, _ = _vis.steady_box2d( 872 None, 873 (get_velocity, [20, 20]), 874 (positions, Ŋ([-2e5, -2e5]), Ŋ([2e5, 2e5])), 875 "xz", 876 (cpo_vectors, misorient_indices), 877 strains, 878 ) 879 fig.savefig(f"{out_basepath}.pdf")
Test alignment of olivine a-axis for a polycrystal advected on a pathline.