tests.test_vortex_2d
PyDRex: tests for CPO stability in 2D vortex and Stokes cell flows.
1"""> PyDRex: tests for CPO stability in 2D vortex and Stokes cell flows.""" 2 3import contextlib as cl 4import functools as ft 5 6import numpy as np 7import pytest 8from numpy import testing as nt 9 10from pydrex import core as _core 11from pydrex import diagnostics as _diagnostics 12from pydrex import io as _io 13from pydrex import logger as _log 14from pydrex import minerals as _minerals 15from pydrex import pathlines as _path 16from pydrex import stats as _stats 17from pydrex import utils as _utils 18from pydrex import velocity as _velocity 19from pydrex import visualisation as _vis 20from pydrex import geometry as _geo 21 22Pool, HAS_RAY = _utils.import_proc_pool() 23 24SUBDIR = "2d_vortex" 25"""Subdirectory of `outdir` used to store outputs from these tests.""" 26 27 28def run_singlephase(params: dict, seed: int, assert_each=None, **kwargs) -> tuple: 29 """Run 2D convection cell simulation for a single mineral phase. 30 31 Uses A-type olivine by default. 32 33 Args: 34 - `params` — see `pydrex.core.DefaultParams` 35 - `seed` — seed for random number generation 36 - `assert_each` — optional callable with signature `f(mineral, 37 deformation_gradient)` that performs assertions at each step 38 39 Optional keyword args are consumed by: 40 1. `pydrex.velocity.cell_2d` and 41 2. the `pydrex.minerals.Mineral` constructor 42 43 Returns a tuple containing: 44 1. the `mineral` (instance of `pydrex.minerals.Mineral`) 45 2. the resampled texture (a tuple of the orientations and fractions) 46 3. a tuple of `fig, ax, q, s` as returned from `pydrex.visualisation.pathline_box2d` 47 48 """ 49 horizontal: str = kwargs.pop("horizontal", "X") 50 vertical: str = kwargs.pop("vertical", "Z") 51 velocity_edge: float = kwargs.pop("velocity_edge", 6.342e-10) 52 edge_length: float = kwargs.pop("edge_length", 2e5) 53 54 max_strain = kwargs.pop( 55 # This should be enough to go around the cell one time. 56 "max_strain", 57 int(np.ceil(velocity_edge * (edge_length / 2) ** 2)), 58 ) 59 get_velocity, get_velocity_gradient = _velocity.cell_2d( 60 horizontal, 61 vertical, 62 velocity_edge, 63 edge_length, 64 ) 65 mineral = _minerals.Mineral( 66 phase=kwargs.pop("phase", _core.MineralPhase.olivine), 67 fabric=kwargs.pop("fabric", _core.MineralFabric.olivine_A), 68 regime=kwargs.pop("regime", _core.DeformationRegime.matrix_dislocation), 69 n_grains=params["number_of_grains"], 70 seed=seed, 71 **kwargs, 72 ) 73 74 size = edge_length / 2 75 dummy_dim = ({0, 1, 2} - set(_geo.to_indices2d(horizontal, vertical))).pop() 76 77 timestamps, get_position = _path.get_pathline( 78 _utils.add_dim([0.5, -0.75], dummy_dim) * size, 79 get_velocity, 80 get_velocity_gradient, 81 _utils.add_dim([-size, -size], dummy_dim), 82 _utils.add_dim([size, size], dummy_dim), 83 max_strain, 84 regular_steps=max_strain * 10, 85 ) 86 positions = [get_position(t) for t in timestamps] 87 velocity_gradients = [ # Steady flow, time variable is np.nan. 88 get_velocity_gradient(np.nan, np.asarray(x)) for x in positions 89 ] 90 91 strains = np.empty_like(timestamps) 92 strains[0] = 0 93 deformation_gradient = np.eye(3) 94 95 for t, time in enumerate(timestamps[:-1], start=1): 96 strains[t] = strains[t - 1] + ( 97 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 98 ) 99 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 100 101 deformation_gradient = mineral.update_orientations( 102 params, 103 deformation_gradient, 104 get_velocity_gradient, 105 pathline=(time, timestamps[t], get_position), 106 ) 107 if assert_each is not None: 108 assert_each(mineral, deformation_gradient) 109 110 orientations, fractions = _stats.resample_orientations( 111 mineral.orientations, mineral.fractions, seed=seed 112 ) 113 cpo_strengths = np.full(len(orientations), 1.0) 114 cpo_vectors = [_diagnostics.bingham_average(o) for o in orientations] 115 fig_path, ax_path, q, s = _vis.steady_box2d( 116 None, 117 (get_velocity, [20, 20]), 118 (positions, [-size, -size], [size, size]), 119 horizontal + vertical, 120 (cpo_strengths, cpo_vectors), 121 strains, 122 cmap="cmc.batlow_r", 123 aspect="equal", 124 alpha=1, 125 ) 126 fig_path.colorbar(s, ax=ax_path, aspect=25, label="Strain (ε)") 127 128 return mineral, (orientations, fractions), (fig_path, ax_path, q, s) 129 130 131class TestCellOlivineA: 132 """Tests for A-type olivine polycrystals in a 2D Stokes cell.""" 133 134 class_id = "cell_olivineA" 135 _ensemble_n_grains = [100, 500, 1000, 5000, 10000] 136 137 @classmethod 138 def _make_ensemble_figure(cls, outdir): 139 # Create the combined figure from outputs of the parametrized ensemble test. 140 data = [] 141 out_basepath = f"{outdir}/{SUBDIR}/{cls.class_id}" 142 try: 143 for n_grains in cls._ensemble_n_grains: 144 data.append(np.load(f"{out_basepath}_xz_ensemble_N{n_grains}_data.npz")) 145 except FileNotFoundError: 146 _log.debug( 147 "skipping visualisation of 2D cell ensemble results (missing datafiles)" 148 ) 149 return 150 151 fig = _vis.figure() 152 mosaic = fig.subplot_mosaic([["a)"], ["b)"]], sharex=True) 153 fig, mosaic["a)"], colors = _vis.alignment( 154 mosaic["a)"], 155 np.asarray([d["strains"] for d in data]), 156 np.asarray([d["angles_mean"] for d in data]), 157 ("s", "o", "v", "*", "."), 158 list(map(str, cls._ensemble_n_grains)), 159 err=np.asarray([d["angles_err"] for d in data]), 160 ) 161 fig, mosaic["b)"], colors = _vis.alignment( 162 mosaic["b)"], 163 np.asarray([d["strains"] for d in data]), 164 np.asarray([d["max_sizes_mean"] for d in data]), 165 ("s", "o", "v", "*", "."), 166 list(map(str, cls._ensemble_n_grains)), 167 err=np.asarray([d["max_sizes_err"] for d in data]), 168 θ_max=4, 169 ) 170 mosaic["b)"].set_ylabel(r"$\log_{10}(\overline{S}_{\mathrm{max}})$") 171 172 mosaic["a)"].label_outer() 173 mosaic["b)"].get_legend().remove() 174 fig.savefig( 175 _io.resolve_path( 176 f"{outdir}/{SUBDIR}/{cls.class_id}_xz_ensemble_combined.pdf" 177 ) 178 ) 179 180 @classmethod 181 def run( 182 cls, 183 params, 184 final_location, 185 get_velocity, 186 get_velocity_gradient, 187 min_coords, 188 max_coords, 189 max_strain, 190 seed=None, 191 ): 192 """Run 2D Stokes cell A-type olivine simulation.""" 193 mineral = _minerals.Mineral( 194 phase=_core.MineralPhase.olivine, 195 fabric=_core.MineralFabric.olivine_A, 196 regime=_core.DeformationRegime.matrix_dislocation, 197 n_grains=params["number_of_grains"], 198 seed=seed, 199 ) 200 deformation_gradient = np.eye(3) 201 202 timestamps, get_position = _path.get_pathline( 203 final_location, 204 get_velocity, 205 get_velocity_gradient, 206 min_coords, 207 max_coords, 208 max_strain, 209 regular_steps=int(max_strain * 10), 210 ) 211 positions = [get_position(t) for t in timestamps] 212 velocity_gradients = [ 213 get_velocity_gradient(np.nan, np.asarray(x)) for x in positions 214 ] 215 strains = np.empty_like(timestamps) 216 strains[0] = 0 217 for t, time in enumerate(timestamps[:-1], start=1): 218 strains[t] = strains[t - 1] + ( 219 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 220 ) 221 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 222 223 deformation_gradient = mineral.update_orientations( 224 params, 225 deformation_gradient, 226 get_velocity_gradient, 227 pathline=(time, timestamps[t], get_position), 228 ) 229 return timestamps, positions, strains, mineral, deformation_gradient 230 231 @pytest.mark.big 232 def test_xz_10k(self, outdir, seed): 233 """Run 2D cell test with 10000 grains (~14GiB RAM requirement).""" 234 self.test_xz(outdir, seed, 10000) 235 236 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 237 @pytest.mark.parametrize("n_grains", [100, 500, 1000, 5000]) 238 def test_xz(self, outdir, seed, n_grains): 239 """Test to check that 5000 grains is "enough" to resolve transient features.""" 240 if outdir is not None: 241 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_xz_N{n_grains}" 242 243 params = _core.DefaultParams().as_dict() 244 params["number_of_grains"] = n_grains 245 get_velocity, get_velocity_gradient = _velocity.cell_2d("X", "Z", 1) 246 247 timestamps, positions, strains, mineral, deformation_gradient = self.run( 248 params, 249 np.asarray([0.5, 0.0, -0.75]), 250 get_velocity, 251 get_velocity_gradient, 252 np.asarray([-1, 0, -1]), 253 np.asarray([1, 0, 1]), 254 7, 255 seed=seed, 256 ) 257 angles = [ 258 _diagnostics.smallest_angle( 259 _diagnostics.bingham_average(a, axis="a"), get_velocity(np.nan, x) 260 ) 261 for a, x in zip(mineral.orientations, positions, strict=True) 262 ] 263 if outdir is not None: 264 # First figure with the domain and pathline. 265 fig_path, ax_path, q, s = _vis.steady_box2d( 266 None, 267 (get_velocity, [20, 20]), 268 (positions, [-1, -1], [1, 1]), 269 "XZ", 270 None, 271 strains, 272 cmap="cmc.batlow_r", 273 tick_formatter=lambda x, pos: str(x), 274 aspect="equal", 275 alpha=1, 276 ) 277 fig_path.colorbar(s, ax=ax_path, aspect=25, label="Strain (ε)") 278 fig_path.savefig(_io.resolve_path(f"{out_basepath}_path.pdf")) 279 # Second figure with the angles and grain sizes at every 10 strain values. 280 fig = _vis.figure() 281 axθ = fig.add_subplot(2, 1, 1) 282 fig, axθ, colors = _vis.alignment( 283 axθ, 284 strains, 285 angles, 286 (".",), 287 (None,), 288 colors=[strains], 289 cmaps=["cmc.batlow_r"], 290 ) 291 ax_sizes = fig.add_subplot(2, 1, 2, sharex=axθ) 292 fig, ax_sizes, parts = _vis.grainsizes( 293 ax_sizes, strains[::10], mineral.fractions[::10] 294 ) 295 axθ.label_outer() 296 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 297 298 # Some checks for when we should have "enough" grains. 299 # Based on empirical model outputs, it seems like the dip at ε ≈ 3.75 is the 300 # least sensitive feature to the random state (seed) so we will use that. 301 if n_grains >= 5000: 302 # Can we resolve the temporary alignment to below 20° at ε ≈ 3.75? 303 mean_θ_in_dip = np.mean(angles[34:43]) 304 assert mean_θ_in_dip < 12, mean_θ_in_dip 305 # Can we resolve corresponding dip in max grain size (normalized, log_10)? 306 mean_size_in_dip = np.log10( 307 np.mean([np.max(f) for f in mineral.fractions[34:43]]) * n_grains 308 ) 309 assert 2 < mean_size_in_dip < 3, mean_size_in_dip 310 # Can we resolve subsequent peak in max grain size (normalized, log_10)? 311 max_size_post_dip = np.log10( 312 np.max([np.max(f) for f in mineral.fractions[43:]]) * n_grains 313 ) 314 assert max_size_post_dip > 3, max_size_post_dip 315 316 @pytest.mark.slow 317 @pytest.mark.parametrize("n_grains", [100, 500, 1000, 5000, 10000]) 318 def test_xz_ensemble(self, outdir, seeds_nearX45, ncpus, n_grains): 319 """Test to demonstrate stability of the dip at ε ≈ 3.75 for 5000+ grains.""" 320 _seeds = seeds_nearX45 321 n_seeds = len(_seeds) 322 if outdir is not None: 323 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_xz_ensemble_N{n_grains}" 324 325 params = _core.DefaultParams().as_dict() 326 params["number_of_grains"] = n_grains 327 get_velocity, get_velocity_gradient = _velocity.cell_2d("X", "Z", 1) 328 329 _run = ft.partial( 330 self.run, 331 params, 332 np.asarray([0.5, 0.0, -0.75]), 333 get_velocity, 334 get_velocity_gradient, 335 np.asarray([-1, 0, -1]), 336 np.asarray([1, 0, 1]), 337 7, 338 ) 339 angles = np.empty((n_seeds, 70)) 340 max_sizes = np.empty_like(angles) 341 with Pool(processes=ncpus) as pool: 342 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 343 timestamps, positions, strains, mineral, deformation_gradient = out 344 angles[s] = [ 345 _diagnostics.smallest_angle( 346 _diagnostics.bingham_average(a, axis="a"), 347 get_velocity(np.nan, x), 348 ) 349 for a, x in zip(mineral.orientations, positions, strict=True) 350 ] 351 max_sizes[s] = np.log10(np.max(mineral.fractions, axis=1) * n_grains) 352 353 if outdir is not None: 354 # Figure with the angles and max grain sizes (ensemble averages). 355 fig = _vis.figure() 356 axθ = fig.add_subplot(2, 1, 1) 357 angles_mean = np.mean(angles, axis=0) 358 angles_err = np.std(angles, axis=0) 359 fig, axθ, colors = _vis.alignment( 360 axθ, 361 strains, 362 angles_mean, 363 (".",), 364 (None,), 365 err=angles_err, 366 ) 367 ax_maxsize = fig.add_subplot(2, 1, 2, sharex=axθ) 368 ax_maxsize.set_ylabel(r"$\log_{10}(\overline{S}_{\mathrm{max}})$") 369 max_sizes_mean = np.mean(max_sizes, axis=0) 370 ax_maxsize.plot(strains, max_sizes_mean, color=colors[0]) 371 max_sizes_err = np.std(max_sizes, axis=0) 372 ax_maxsize.fill_between( 373 strains, 374 max_sizes_mean - max_sizes_err, 375 max_sizes_mean + max_sizes_err, 376 alpha=0.22, 377 color=colors[0], 378 ) 379 axθ.label_outer() 380 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 381 np.savez( 382 _io.resolve_path(f"{out_basepath}_data.npz"), 383 strains=strains, 384 max_sizes_mean=max_sizes_mean, 385 max_sizes_err=max_sizes_err, 386 angles_mean=angles_mean, 387 angles_err=angles_err, 388 ) 389 390 391class TestDiffusionCreep: 392 """Tests for diffusion creep regime.""" 393 394 class_id = "diff_creep" 395 396 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 397 def test_cell_olA(self, outdir, seed, ncpus, orientations_init_y): 398 params = _core.DefaultParams().as_dict() 399 params["gbm_mobility"] = 10 400 401 def get_assert_each(i): # The order of orientations_init_y is significant. 402 @_utils.serializable 403 def assert_each(mineral, deformation_gradient): 404 # Check that surrogate grain sizes are not changing. 405 nt.assert_allclose( 406 mineral.fractions[-1], mineral.fractions[-2], atol=1e-16, rtol=0 407 ) 408 p, g, r = _diagnostics.symmetry_pgr(mineral.orientations[-1]) 409 nt.assert_allclose( 410 np.array([p, g, r]), 411 _diagnostics.symmetry_pgr(mineral.orientations[-2]), 412 atol=0.25, 413 rtol=0, 414 ) 415 match i: 416 case 0: 417 # Check that symmetry remains mostly random. 418 assert r > 0.9, f"{r}" 419 case 1: 420 # Check that symmetry remains mostly girdled. 421 assert g > 0.9, f"{g}" 422 case 2: 423 # Check that symmetry remains mostly clustered. 424 assert p > 0.9, f"{g}" 425 426 return assert_each 427 428 @_utils.serializable 429 def _run(assert_each, orientations_init): 430 return run_singlephase( 431 params, 432 seed, 433 regime=_core.DeformationRegime.matrix_diffusion, 434 orientations_init=orientations_init, 435 ) 436 437 optional_logging = cl.nullcontext() 438 if outdir is not None: 439 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_olA" 440 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 441 442 assert_each_list = [ 443 get_assert_each(i) for i, _ in enumerate(orientations_init_y) 444 ] 445 orientations_init_list = [ 446 f(params["number_of_grains"]) for f in orientations_init_y 447 ] 448 with optional_logging: 449 with Pool(processes=ncpus) as pool: 450 for i, out in enumerate( 451 pool.starmap(_run, zip(assert_each_list, orientations_init_list)) 452 ): 453 mineral, resampled_texture, fig_objects = out 454 if outdir is not None: 455 fig_objects[0].savefig( 456 _io.resolve_path(f"{out_basepath}_path_{i}.pdf") 457 )
SUBDIR =
'2d_vortex'
Subdirectory of outdir
used to store outputs from these tests.
def
run_singlephase(params: dict, seed: int, assert_each=None, **kwargs) -> tuple:
29def run_singlephase(params: dict, seed: int, assert_each=None, **kwargs) -> tuple: 30 """Run 2D convection cell simulation for a single mineral phase. 31 32 Uses A-type olivine by default. 33 34 Args: 35 - `params` — see `pydrex.core.DefaultParams` 36 - `seed` — seed for random number generation 37 - `assert_each` — optional callable with signature `f(mineral, 38 deformation_gradient)` that performs assertions at each step 39 40 Optional keyword args are consumed by: 41 1. `pydrex.velocity.cell_2d` and 42 2. the `pydrex.minerals.Mineral` constructor 43 44 Returns a tuple containing: 45 1. the `mineral` (instance of `pydrex.minerals.Mineral`) 46 2. the resampled texture (a tuple of the orientations and fractions) 47 3. a tuple of `fig, ax, q, s` as returned from `pydrex.visualisation.pathline_box2d` 48 49 """ 50 horizontal: str = kwargs.pop("horizontal", "X") 51 vertical: str = kwargs.pop("vertical", "Z") 52 velocity_edge: float = kwargs.pop("velocity_edge", 6.342e-10) 53 edge_length: float = kwargs.pop("edge_length", 2e5) 54 55 max_strain = kwargs.pop( 56 # This should be enough to go around the cell one time. 57 "max_strain", 58 int(np.ceil(velocity_edge * (edge_length / 2) ** 2)), 59 ) 60 get_velocity, get_velocity_gradient = _velocity.cell_2d( 61 horizontal, 62 vertical, 63 velocity_edge, 64 edge_length, 65 ) 66 mineral = _minerals.Mineral( 67 phase=kwargs.pop("phase", _core.MineralPhase.olivine), 68 fabric=kwargs.pop("fabric", _core.MineralFabric.olivine_A), 69 regime=kwargs.pop("regime", _core.DeformationRegime.matrix_dislocation), 70 n_grains=params["number_of_grains"], 71 seed=seed, 72 **kwargs, 73 ) 74 75 size = edge_length / 2 76 dummy_dim = ({0, 1, 2} - set(_geo.to_indices2d(horizontal, vertical))).pop() 77 78 timestamps, get_position = _path.get_pathline( 79 _utils.add_dim([0.5, -0.75], dummy_dim) * size, 80 get_velocity, 81 get_velocity_gradient, 82 _utils.add_dim([-size, -size], dummy_dim), 83 _utils.add_dim([size, size], dummy_dim), 84 max_strain, 85 regular_steps=max_strain * 10, 86 ) 87 positions = [get_position(t) for t in timestamps] 88 velocity_gradients = [ # Steady flow, time variable is np.nan. 89 get_velocity_gradient(np.nan, np.asarray(x)) for x in positions 90 ] 91 92 strains = np.empty_like(timestamps) 93 strains[0] = 0 94 deformation_gradient = np.eye(3) 95 96 for t, time in enumerate(timestamps[:-1], start=1): 97 strains[t] = strains[t - 1] + ( 98 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 99 ) 100 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 101 102 deformation_gradient = mineral.update_orientations( 103 params, 104 deformation_gradient, 105 get_velocity_gradient, 106 pathline=(time, timestamps[t], get_position), 107 ) 108 if assert_each is not None: 109 assert_each(mineral, deformation_gradient) 110 111 orientations, fractions = _stats.resample_orientations( 112 mineral.orientations, mineral.fractions, seed=seed 113 ) 114 cpo_strengths = np.full(len(orientations), 1.0) 115 cpo_vectors = [_diagnostics.bingham_average(o) for o in orientations] 116 fig_path, ax_path, q, s = _vis.steady_box2d( 117 None, 118 (get_velocity, [20, 20]), 119 (positions, [-size, -size], [size, size]), 120 horizontal + vertical, 121 (cpo_strengths, cpo_vectors), 122 strains, 123 cmap="cmc.batlow_r", 124 aspect="equal", 125 alpha=1, 126 ) 127 fig_path.colorbar(s, ax=ax_path, aspect=25, label="Strain (ε)") 128 129 return mineral, (orientations, fractions), (fig_path, ax_path, q, s)
Run 2D convection cell simulation for a single mineral phase.
Uses A-type olivine by default.
Args:
params
— seepydrex.core.DefaultParams
seed
— seed for random number generationassert_each
— optional callable with signaturef(mineral, deformation_gradient)
that performs assertions at each step
Optional keyword args are consumed by:
pydrex.velocity.cell_2d
and- the
pydrex.minerals.Mineral
constructor
Returns a tuple containing:
- the
mineral
(instance ofpydrex.minerals.Mineral
) - the resampled texture (a tuple of the orientations and fractions)
- a tuple of
fig, ax, q, s
as returned frompydrex.visualisation.pathline_box2d
class
TestCellOlivineA:
132class TestCellOlivineA: 133 """Tests for A-type olivine polycrystals in a 2D Stokes cell.""" 134 135 class_id = "cell_olivineA" 136 _ensemble_n_grains = [100, 500, 1000, 5000, 10000] 137 138 @classmethod 139 def _make_ensemble_figure(cls, outdir): 140 # Create the combined figure from outputs of the parametrized ensemble test. 141 data = [] 142 out_basepath = f"{outdir}/{SUBDIR}/{cls.class_id}" 143 try: 144 for n_grains in cls._ensemble_n_grains: 145 data.append(np.load(f"{out_basepath}_xz_ensemble_N{n_grains}_data.npz")) 146 except FileNotFoundError: 147 _log.debug( 148 "skipping visualisation of 2D cell ensemble results (missing datafiles)" 149 ) 150 return 151 152 fig = _vis.figure() 153 mosaic = fig.subplot_mosaic([["a)"], ["b)"]], sharex=True) 154 fig, mosaic["a)"], colors = _vis.alignment( 155 mosaic["a)"], 156 np.asarray([d["strains"] for d in data]), 157 np.asarray([d["angles_mean"] for d in data]), 158 ("s", "o", "v", "*", "."), 159 list(map(str, cls._ensemble_n_grains)), 160 err=np.asarray([d["angles_err"] for d in data]), 161 ) 162 fig, mosaic["b)"], colors = _vis.alignment( 163 mosaic["b)"], 164 np.asarray([d["strains"] for d in data]), 165 np.asarray([d["max_sizes_mean"] for d in data]), 166 ("s", "o", "v", "*", "."), 167 list(map(str, cls._ensemble_n_grains)), 168 err=np.asarray([d["max_sizes_err"] for d in data]), 169 θ_max=4, 170 ) 171 mosaic["b)"].set_ylabel(r"$\log_{10}(\overline{S}_{\mathrm{max}})$") 172 173 mosaic["a)"].label_outer() 174 mosaic["b)"].get_legend().remove() 175 fig.savefig( 176 _io.resolve_path( 177 f"{outdir}/{SUBDIR}/{cls.class_id}_xz_ensemble_combined.pdf" 178 ) 179 ) 180 181 @classmethod 182 def run( 183 cls, 184 params, 185 final_location, 186 get_velocity, 187 get_velocity_gradient, 188 min_coords, 189 max_coords, 190 max_strain, 191 seed=None, 192 ): 193 """Run 2D Stokes cell A-type olivine simulation.""" 194 mineral = _minerals.Mineral( 195 phase=_core.MineralPhase.olivine, 196 fabric=_core.MineralFabric.olivine_A, 197 regime=_core.DeformationRegime.matrix_dislocation, 198 n_grains=params["number_of_grains"], 199 seed=seed, 200 ) 201 deformation_gradient = np.eye(3) 202 203 timestamps, get_position = _path.get_pathline( 204 final_location, 205 get_velocity, 206 get_velocity_gradient, 207 min_coords, 208 max_coords, 209 max_strain, 210 regular_steps=int(max_strain * 10), 211 ) 212 positions = [get_position(t) for t in timestamps] 213 velocity_gradients = [ 214 get_velocity_gradient(np.nan, np.asarray(x)) for x in positions 215 ] 216 strains = np.empty_like(timestamps) 217 strains[0] = 0 218 for t, time in enumerate(timestamps[:-1], start=1): 219 strains[t] = strains[t - 1] + ( 220 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 221 ) 222 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 223 224 deformation_gradient = mineral.update_orientations( 225 params, 226 deformation_gradient, 227 get_velocity_gradient, 228 pathline=(time, timestamps[t], get_position), 229 ) 230 return timestamps, positions, strains, mineral, deformation_gradient 231 232 @pytest.mark.big 233 def test_xz_10k(self, outdir, seed): 234 """Run 2D cell test with 10000 grains (~14GiB RAM requirement).""" 235 self.test_xz(outdir, seed, 10000) 236 237 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 238 @pytest.mark.parametrize("n_grains", [100, 500, 1000, 5000]) 239 def test_xz(self, outdir, seed, n_grains): 240 """Test to check that 5000 grains is "enough" to resolve transient features.""" 241 if outdir is not None: 242 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_xz_N{n_grains}" 243 244 params = _core.DefaultParams().as_dict() 245 params["number_of_grains"] = n_grains 246 get_velocity, get_velocity_gradient = _velocity.cell_2d("X", "Z", 1) 247 248 timestamps, positions, strains, mineral, deformation_gradient = self.run( 249 params, 250 np.asarray([0.5, 0.0, -0.75]), 251 get_velocity, 252 get_velocity_gradient, 253 np.asarray([-1, 0, -1]), 254 np.asarray([1, 0, 1]), 255 7, 256 seed=seed, 257 ) 258 angles = [ 259 _diagnostics.smallest_angle( 260 _diagnostics.bingham_average(a, axis="a"), get_velocity(np.nan, x) 261 ) 262 for a, x in zip(mineral.orientations, positions, strict=True) 263 ] 264 if outdir is not None: 265 # First figure with the domain and pathline. 266 fig_path, ax_path, q, s = _vis.steady_box2d( 267 None, 268 (get_velocity, [20, 20]), 269 (positions, [-1, -1], [1, 1]), 270 "XZ", 271 None, 272 strains, 273 cmap="cmc.batlow_r", 274 tick_formatter=lambda x, pos: str(x), 275 aspect="equal", 276 alpha=1, 277 ) 278 fig_path.colorbar(s, ax=ax_path, aspect=25, label="Strain (ε)") 279 fig_path.savefig(_io.resolve_path(f"{out_basepath}_path.pdf")) 280 # Second figure with the angles and grain sizes at every 10 strain values. 281 fig = _vis.figure() 282 axθ = fig.add_subplot(2, 1, 1) 283 fig, axθ, colors = _vis.alignment( 284 axθ, 285 strains, 286 angles, 287 (".",), 288 (None,), 289 colors=[strains], 290 cmaps=["cmc.batlow_r"], 291 ) 292 ax_sizes = fig.add_subplot(2, 1, 2, sharex=axθ) 293 fig, ax_sizes, parts = _vis.grainsizes( 294 ax_sizes, strains[::10], mineral.fractions[::10] 295 ) 296 axθ.label_outer() 297 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 298 299 # Some checks for when we should have "enough" grains. 300 # Based on empirical model outputs, it seems like the dip at ε ≈ 3.75 is the 301 # least sensitive feature to the random state (seed) so we will use that. 302 if n_grains >= 5000: 303 # Can we resolve the temporary alignment to below 20° at ε ≈ 3.75? 304 mean_θ_in_dip = np.mean(angles[34:43]) 305 assert mean_θ_in_dip < 12, mean_θ_in_dip 306 # Can we resolve corresponding dip in max grain size (normalized, log_10)? 307 mean_size_in_dip = np.log10( 308 np.mean([np.max(f) for f in mineral.fractions[34:43]]) * n_grains 309 ) 310 assert 2 < mean_size_in_dip < 3, mean_size_in_dip 311 # Can we resolve subsequent peak in max grain size (normalized, log_10)? 312 max_size_post_dip = np.log10( 313 np.max([np.max(f) for f in mineral.fractions[43:]]) * n_grains 314 ) 315 assert max_size_post_dip > 3, max_size_post_dip 316 317 @pytest.mark.slow 318 @pytest.mark.parametrize("n_grains", [100, 500, 1000, 5000, 10000]) 319 def test_xz_ensemble(self, outdir, seeds_nearX45, ncpus, n_grains): 320 """Test to demonstrate stability of the dip at ε ≈ 3.75 for 5000+ grains.""" 321 _seeds = seeds_nearX45 322 n_seeds = len(_seeds) 323 if outdir is not None: 324 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_xz_ensemble_N{n_grains}" 325 326 params = _core.DefaultParams().as_dict() 327 params["number_of_grains"] = n_grains 328 get_velocity, get_velocity_gradient = _velocity.cell_2d("X", "Z", 1) 329 330 _run = ft.partial( 331 self.run, 332 params, 333 np.asarray([0.5, 0.0, -0.75]), 334 get_velocity, 335 get_velocity_gradient, 336 np.asarray([-1, 0, -1]), 337 np.asarray([1, 0, 1]), 338 7, 339 ) 340 angles = np.empty((n_seeds, 70)) 341 max_sizes = np.empty_like(angles) 342 with Pool(processes=ncpus) as pool: 343 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 344 timestamps, positions, strains, mineral, deformation_gradient = out 345 angles[s] = [ 346 _diagnostics.smallest_angle( 347 _diagnostics.bingham_average(a, axis="a"), 348 get_velocity(np.nan, x), 349 ) 350 for a, x in zip(mineral.orientations, positions, strict=True) 351 ] 352 max_sizes[s] = np.log10(np.max(mineral.fractions, axis=1) * n_grains) 353 354 if outdir is not None: 355 # Figure with the angles and max grain sizes (ensemble averages). 356 fig = _vis.figure() 357 axθ = fig.add_subplot(2, 1, 1) 358 angles_mean = np.mean(angles, axis=0) 359 angles_err = np.std(angles, axis=0) 360 fig, axθ, colors = _vis.alignment( 361 axθ, 362 strains, 363 angles_mean, 364 (".",), 365 (None,), 366 err=angles_err, 367 ) 368 ax_maxsize = fig.add_subplot(2, 1, 2, sharex=axθ) 369 ax_maxsize.set_ylabel(r"$\log_{10}(\overline{S}_{\mathrm{max}})$") 370 max_sizes_mean = np.mean(max_sizes, axis=0) 371 ax_maxsize.plot(strains, max_sizes_mean, color=colors[0]) 372 max_sizes_err = np.std(max_sizes, axis=0) 373 ax_maxsize.fill_between( 374 strains, 375 max_sizes_mean - max_sizes_err, 376 max_sizes_mean + max_sizes_err, 377 alpha=0.22, 378 color=colors[0], 379 ) 380 axθ.label_outer() 381 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 382 np.savez( 383 _io.resolve_path(f"{out_basepath}_data.npz"), 384 strains=strains, 385 max_sizes_mean=max_sizes_mean, 386 max_sizes_err=max_sizes_err, 387 angles_mean=angles_mean, 388 angles_err=angles_err, 389 )
Tests for A-type olivine polycrystals in a 2D Stokes cell.
@classmethod
def
run( cls, params, final_location, get_velocity, get_velocity_gradient, min_coords, max_coords, max_strain, seed=None):
181 @classmethod 182 def run( 183 cls, 184 params, 185 final_location, 186 get_velocity, 187 get_velocity_gradient, 188 min_coords, 189 max_coords, 190 max_strain, 191 seed=None, 192 ): 193 """Run 2D Stokes cell A-type olivine simulation.""" 194 mineral = _minerals.Mineral( 195 phase=_core.MineralPhase.olivine, 196 fabric=_core.MineralFabric.olivine_A, 197 regime=_core.DeformationRegime.matrix_dislocation, 198 n_grains=params["number_of_grains"], 199 seed=seed, 200 ) 201 deformation_gradient = np.eye(3) 202 203 timestamps, get_position = _path.get_pathline( 204 final_location, 205 get_velocity, 206 get_velocity_gradient, 207 min_coords, 208 max_coords, 209 max_strain, 210 regular_steps=int(max_strain * 10), 211 ) 212 positions = [get_position(t) for t in timestamps] 213 velocity_gradients = [ 214 get_velocity_gradient(np.nan, np.asarray(x)) for x in positions 215 ] 216 strains = np.empty_like(timestamps) 217 strains[0] = 0 218 for t, time in enumerate(timestamps[:-1], start=1): 219 strains[t] = strains[t - 1] + ( 220 _utils.strain_increment(timestamps[t] - time, velocity_gradients[t]) 221 ) 222 _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t]) 223 224 deformation_gradient = mineral.update_orientations( 225 params, 226 deformation_gradient, 227 get_velocity_gradient, 228 pathline=(time, timestamps[t], get_position), 229 ) 230 return timestamps, positions, strains, mineral, deformation_gradient
Run 2D Stokes cell A-type olivine simulation.
@pytest.mark.big
def
test_xz_10k(self, outdir, seed):
232 @pytest.mark.big 233 def test_xz_10k(self, outdir, seed): 234 """Run 2D cell test with 10000 grains (~14GiB RAM requirement).""" 235 self.test_xz(outdir, seed, 10000)
Run 2D cell test with 10000 grains (~14GiB RAM requirement).
@pytest.mark.skipif(_utils.in_ci('win32'), reason='Unable to allocate memory')
@pytest.mark.parametrize('n_grains', [100, 500, 1000, 5000])
def
test_xz(self, outdir, seed, n_grains):
237 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 238 @pytest.mark.parametrize("n_grains", [100, 500, 1000, 5000]) 239 def test_xz(self, outdir, seed, n_grains): 240 """Test to check that 5000 grains is "enough" to resolve transient features.""" 241 if outdir is not None: 242 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_xz_N{n_grains}" 243 244 params = _core.DefaultParams().as_dict() 245 params["number_of_grains"] = n_grains 246 get_velocity, get_velocity_gradient = _velocity.cell_2d("X", "Z", 1) 247 248 timestamps, positions, strains, mineral, deformation_gradient = self.run( 249 params, 250 np.asarray([0.5, 0.0, -0.75]), 251 get_velocity, 252 get_velocity_gradient, 253 np.asarray([-1, 0, -1]), 254 np.asarray([1, 0, 1]), 255 7, 256 seed=seed, 257 ) 258 angles = [ 259 _diagnostics.smallest_angle( 260 _diagnostics.bingham_average(a, axis="a"), get_velocity(np.nan, x) 261 ) 262 for a, x in zip(mineral.orientations, positions, strict=True) 263 ] 264 if outdir is not None: 265 # First figure with the domain and pathline. 266 fig_path, ax_path, q, s = _vis.steady_box2d( 267 None, 268 (get_velocity, [20, 20]), 269 (positions, [-1, -1], [1, 1]), 270 "XZ", 271 None, 272 strains, 273 cmap="cmc.batlow_r", 274 tick_formatter=lambda x, pos: str(x), 275 aspect="equal", 276 alpha=1, 277 ) 278 fig_path.colorbar(s, ax=ax_path, aspect=25, label="Strain (ε)") 279 fig_path.savefig(_io.resolve_path(f"{out_basepath}_path.pdf")) 280 # Second figure with the angles and grain sizes at every 10 strain values. 281 fig = _vis.figure() 282 axθ = fig.add_subplot(2, 1, 1) 283 fig, axθ, colors = _vis.alignment( 284 axθ, 285 strains, 286 angles, 287 (".",), 288 (None,), 289 colors=[strains], 290 cmaps=["cmc.batlow_r"], 291 ) 292 ax_sizes = fig.add_subplot(2, 1, 2, sharex=axθ) 293 fig, ax_sizes, parts = _vis.grainsizes( 294 ax_sizes, strains[::10], mineral.fractions[::10] 295 ) 296 axθ.label_outer() 297 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 298 299 # Some checks for when we should have "enough" grains. 300 # Based on empirical model outputs, it seems like the dip at ε ≈ 3.75 is the 301 # least sensitive feature to the random state (seed) so we will use that. 302 if n_grains >= 5000: 303 # Can we resolve the temporary alignment to below 20° at ε ≈ 3.75? 304 mean_θ_in_dip = np.mean(angles[34:43]) 305 assert mean_θ_in_dip < 12, mean_θ_in_dip 306 # Can we resolve corresponding dip in max grain size (normalized, log_10)? 307 mean_size_in_dip = np.log10( 308 np.mean([np.max(f) for f in mineral.fractions[34:43]]) * n_grains 309 ) 310 assert 2 < mean_size_in_dip < 3, mean_size_in_dip 311 # Can we resolve subsequent peak in max grain size (normalized, log_10)? 312 max_size_post_dip = np.log10( 313 np.max([np.max(f) for f in mineral.fractions[43:]]) * n_grains 314 ) 315 assert max_size_post_dip > 3, max_size_post_dip
Test to check that 5000 grains is "enough" to resolve transient features.
@pytest.mark.slow
@pytest.mark.parametrize('n_grains', [100, 500, 1000, 5000, 10000])
def
test_xz_ensemble(self, outdir, seeds_nearX45, ncpus, n_grains):
317 @pytest.mark.slow 318 @pytest.mark.parametrize("n_grains", [100, 500, 1000, 5000, 10000]) 319 def test_xz_ensemble(self, outdir, seeds_nearX45, ncpus, n_grains): 320 """Test to demonstrate stability of the dip at ε ≈ 3.75 for 5000+ grains.""" 321 _seeds = seeds_nearX45 322 n_seeds = len(_seeds) 323 if outdir is not None: 324 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_xz_ensemble_N{n_grains}" 325 326 params = _core.DefaultParams().as_dict() 327 params["number_of_grains"] = n_grains 328 get_velocity, get_velocity_gradient = _velocity.cell_2d("X", "Z", 1) 329 330 _run = ft.partial( 331 self.run, 332 params, 333 np.asarray([0.5, 0.0, -0.75]), 334 get_velocity, 335 get_velocity_gradient, 336 np.asarray([-1, 0, -1]), 337 np.asarray([1, 0, 1]), 338 7, 339 ) 340 angles = np.empty((n_seeds, 70)) 341 max_sizes = np.empty_like(angles) 342 with Pool(processes=ncpus) as pool: 343 for s, out in enumerate(pool.imap_unordered(_run, _seeds)): 344 timestamps, positions, strains, mineral, deformation_gradient = out 345 angles[s] = [ 346 _diagnostics.smallest_angle( 347 _diagnostics.bingham_average(a, axis="a"), 348 get_velocity(np.nan, x), 349 ) 350 for a, x in zip(mineral.orientations, positions, strict=True) 351 ] 352 max_sizes[s] = np.log10(np.max(mineral.fractions, axis=1) * n_grains) 353 354 if outdir is not None: 355 # Figure with the angles and max grain sizes (ensemble averages). 356 fig = _vis.figure() 357 axθ = fig.add_subplot(2, 1, 1) 358 angles_mean = np.mean(angles, axis=0) 359 angles_err = np.std(angles, axis=0) 360 fig, axθ, colors = _vis.alignment( 361 axθ, 362 strains, 363 angles_mean, 364 (".",), 365 (None,), 366 err=angles_err, 367 ) 368 ax_maxsize = fig.add_subplot(2, 1, 2, sharex=axθ) 369 ax_maxsize.set_ylabel(r"$\log_{10}(\overline{S}_{\mathrm{max}})$") 370 max_sizes_mean = np.mean(max_sizes, axis=0) 371 ax_maxsize.plot(strains, max_sizes_mean, color=colors[0]) 372 max_sizes_err = np.std(max_sizes, axis=0) 373 ax_maxsize.fill_between( 374 strains, 375 max_sizes_mean - max_sizes_err, 376 max_sizes_mean + max_sizes_err, 377 alpha=0.22, 378 color=colors[0], 379 ) 380 axθ.label_outer() 381 fig.savefig(_io.resolve_path(f"{out_basepath}.pdf")) 382 np.savez( 383 _io.resolve_path(f"{out_basepath}_data.npz"), 384 strains=strains, 385 max_sizes_mean=max_sizes_mean, 386 max_sizes_err=max_sizes_err, 387 angles_mean=angles_mean, 388 angles_err=angles_err, 389 )
Test to demonstrate stability of the dip at ε ≈ 3.75 for 5000+ grains.
class
TestDiffusionCreep:
392class TestDiffusionCreep: 393 """Tests for diffusion creep regime.""" 394 395 class_id = "diff_creep" 396 397 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 398 def test_cell_olA(self, outdir, seed, ncpus, orientations_init_y): 399 params = _core.DefaultParams().as_dict() 400 params["gbm_mobility"] = 10 401 402 def get_assert_each(i): # The order of orientations_init_y is significant. 403 @_utils.serializable 404 def assert_each(mineral, deformation_gradient): 405 # Check that surrogate grain sizes are not changing. 406 nt.assert_allclose( 407 mineral.fractions[-1], mineral.fractions[-2], atol=1e-16, rtol=0 408 ) 409 p, g, r = _diagnostics.symmetry_pgr(mineral.orientations[-1]) 410 nt.assert_allclose( 411 np.array([p, g, r]), 412 _diagnostics.symmetry_pgr(mineral.orientations[-2]), 413 atol=0.25, 414 rtol=0, 415 ) 416 match i: 417 case 0: 418 # Check that symmetry remains mostly random. 419 assert r > 0.9, f"{r}" 420 case 1: 421 # Check that symmetry remains mostly girdled. 422 assert g > 0.9, f"{g}" 423 case 2: 424 # Check that symmetry remains mostly clustered. 425 assert p > 0.9, f"{g}" 426 427 return assert_each 428 429 @_utils.serializable 430 def _run(assert_each, orientations_init): 431 return run_singlephase( 432 params, 433 seed, 434 regime=_core.DeformationRegime.matrix_diffusion, 435 orientations_init=orientations_init, 436 ) 437 438 optional_logging = cl.nullcontext() 439 if outdir is not None: 440 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_olA" 441 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 442 443 assert_each_list = [ 444 get_assert_each(i) for i, _ in enumerate(orientations_init_y) 445 ] 446 orientations_init_list = [ 447 f(params["number_of_grains"]) for f in orientations_init_y 448 ] 449 with optional_logging: 450 with Pool(processes=ncpus) as pool: 451 for i, out in enumerate( 452 pool.starmap(_run, zip(assert_each_list, orientations_init_list)) 453 ): 454 mineral, resampled_texture, fig_objects = out 455 if outdir is not None: 456 fig_objects[0].savefig( 457 _io.resolve_path(f"{out_basepath}_path_{i}.pdf") 458 )
Tests for diffusion creep regime.
@pytest.mark.skipif(_utils.in_ci('win32'), reason='Unable to allocate memory')
def
test_cell_olA(self, outdir, seed, ncpus, orientations_init_y):
397 @pytest.mark.skipif(_utils.in_ci("win32"), reason="Unable to allocate memory") 398 def test_cell_olA(self, outdir, seed, ncpus, orientations_init_y): 399 params = _core.DefaultParams().as_dict() 400 params["gbm_mobility"] = 10 401 402 def get_assert_each(i): # The order of orientations_init_y is significant. 403 @_utils.serializable 404 def assert_each(mineral, deformation_gradient): 405 # Check that surrogate grain sizes are not changing. 406 nt.assert_allclose( 407 mineral.fractions[-1], mineral.fractions[-2], atol=1e-16, rtol=0 408 ) 409 p, g, r = _diagnostics.symmetry_pgr(mineral.orientations[-1]) 410 nt.assert_allclose( 411 np.array([p, g, r]), 412 _diagnostics.symmetry_pgr(mineral.orientations[-2]), 413 atol=0.25, 414 rtol=0, 415 ) 416 match i: 417 case 0: 418 # Check that symmetry remains mostly random. 419 assert r > 0.9, f"{r}" 420 case 1: 421 # Check that symmetry remains mostly girdled. 422 assert g > 0.9, f"{g}" 423 case 2: 424 # Check that symmetry remains mostly clustered. 425 assert p > 0.9, f"{g}" 426 427 return assert_each 428 429 @_utils.serializable 430 def _run(assert_each, orientations_init): 431 return run_singlephase( 432 params, 433 seed, 434 regime=_core.DeformationRegime.matrix_diffusion, 435 orientations_init=orientations_init, 436 ) 437 438 optional_logging = cl.nullcontext() 439 if outdir is not None: 440 out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_olA" 441 optional_logging = _io.logfile_enable(f"{out_basepath}.log") 442 443 assert_each_list = [ 444 get_assert_each(i) for i, _ in enumerate(orientations_init_y) 445 ] 446 orientations_init_list = [ 447 f(params["number_of_grains"]) for f in orientations_init_y 448 ] 449 with optional_logging: 450 with Pool(processes=ncpus) as pool: 451 for i, out in enumerate( 452 pool.starmap(_run, zip(assert_each_list, orientations_init_list)) 453 ): 454 mineral, resampled_texture, fig_objects = out 455 if outdir is not None: 456 fig_objects[0].savefig( 457 _io.resolve_path(f"{out_basepath}_path_{i}.pdf") 458 )