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")
SUBDIR = '2d_simple_shear'
class TestPreliminaries:
 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.

def test_strain_increment(self):
 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.

class TestOlivineA:
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.

class_id = 'olivineA'
@classmethod
def run( cls, params, timestamps, strain_rate, get_velocity_gradient, shear_direction, seed=None, return_fse=None, get_position=<function TestOlivineA.<lambda>>):
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).

@classmethod
def interp_GBM_Kaminski2001(cls, strains):
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.

@classmethod
def interp_GBM_FortranDRex(cls, 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'.

@classmethod
def interp_GBS_FortranDRex(cls, strains):
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'.

@classmethod
def interp_GBS_long_FortranDRex(cls, strains):
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'.

@classmethod
def interp_GBS_Kaminski2004(cls, strains):
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.

@pytest.mark.skipif(_utils.in_ci('win32'), reason='Unable to allocate memory')
def test_zero_recrystallisation(self, seed):
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.

@pytest.mark.skipif(_utils.in_ci('win32'), reason='Unable to allocate memory')
@pytest.mark.parametrize('gbm_mobility', [50, 100, 150])
def test_grainsize_median(self, seed, gbm_mobility):
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.

@pytest.mark.slow
@pytest.mark.parametrize('gbs_threshold', [0, 0.2, 0.4])
@pytest.mark.parametrize('nucleation_efficiency', [3, 5, 10])
def test_dvdx_ensemble( self, outdir, seeds_nearX45, ncpus, gbs_threshold, nucleation_efficiency):
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}$$

@pytest.mark.slow
def test_dvdx_GBM(self, outdir, seeds_nearX45, ncpus):
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.

@pytest.mark.slow
def test_GBM_calibration(self, outdir, seeds, ncpus):
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.

@pytest.mark.big
def test_dudz_pathline(self, outdir, seed):
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.