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 — see pydrex.core.DefaultParams
  • seed — seed for random number generation
  • assert_each — optional callable with signature f(mineral, deformation_gradient) that performs assertions at each step

Optional keyword args are consumed by:

  1. pydrex.velocity.cell_2d and
  2. the pydrex.minerals.Mineral constructor

Returns a tuple containing:

  1. the mineral (instance of pydrex.minerals.Mineral)
  2. the resampled texture (a tuple of the orientations and fractions)
  3. a tuple of fig, ax, q, s as returned from pydrex.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.

class_id = 'cell_olivineA'
@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.

class_id = 'diff_creep'
@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                        )