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
  5import sys
  6from time import process_time
  7
  8import numpy as np
  9import pytest
 10from numpy import asarray as Ŋ
 11from numpy import testing as nt
 12from scipy.interpolate import PchipInterpolator
 13
 14from pydrex import core as _core
 15from pydrex import diagnostics as _diagnostics
 16from pydrex import geometry as _geo
 17from pydrex import io as _io
 18from pydrex import logger as _log
 19from pydrex import minerals as _minerals
 20from pydrex import pathlines as _paths
 21from pydrex import stats as _stats
 22from pydrex import utils as _utils
 23from pydrex import velocity as _velocity
 24from pydrex import visualisation as _vis
 25
 26Pool, HAS_RAY = _utils.import_proc_pool()
 27
 28# Subdirectory of `outdir` used to store outputs from these tests.
 29SUBDIR = "2d_simple_shear"
 30
 31
 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        )
110
111
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")
SUBDIR = '2d_simple_shear'
class TestPreliminaries:
 33class TestPreliminaries:
 34    """Preliminary tests to check that various auxiliary routines are working."""
 35
 36    def test_strain_increment(self):
 37        """Test for accumulating strain via strain increment calculations."""
 38        _, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1)
 39        timestamps = np.linspace(0, 1, 10)  # Solve until D₀t=1 (tensorial strain).
 40        strains_inc = np.zeros_like(timestamps)
 41        L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0]))
 42        for i, ε in enumerate(strains_inc[1:]):
 43            strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment(
 44                timestamps[1] - timestamps[0],
 45                L,
 46            )
 47        # For constant timesteps, check strains == positive_timestamps * strain_rate.
 48        nt.assert_allclose(strains_inc, timestamps, atol=6e-16, rtol=0)
 49
 50        # Same thing, but for strain rate similar to experiments.
 51        _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", 1e-5)
 52        timestamps = np.linspace(0, 1e6, 10)  # Solve until D₀t=10 (tensorial strain).
 53        strains_inc = np.zeros_like(timestamps)
 54        L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0]))
 55        for i, ε in enumerate(strains_inc[1:]):
 56            strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment(
 57                timestamps[1] - timestamps[0],
 58                L,
 59            )
 60        nt.assert_allclose(strains_inc, timestamps * 1e-5, atol=5e-15, rtol=0)
 61
 62        # Again, but this time the particle will move (using get_pathline).
 63        # We use a 400km x 400km box and a strain rate of 1e-15 s⁻¹.
 64        get_velocity, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1e-15)
 65        timestamps, get_position = _paths.get_pathline(
 66            Ŋ([1e5, 0e0, 1e5]),
 67            get_velocity,
 68            get_velocity_gradient,
 69            Ŋ([-2e5, 0e0, -2e5]),
 70            Ŋ([2e5, 0e0, 2e5]),
 71            2,
 72            regular_steps=10,
 73        )
 74        positions = [get_position(t) for t in timestamps]
 75        velocity_gradients = [get_velocity_gradient(np.nan, Ŋ(x)) for x in positions]
 76
 77        # Check that polycrystal is experiencing steady velocity gradient.
 78        nt.assert_array_equal(
 79            velocity_gradients, np.full_like(velocity_gradients, velocity_gradients[0])
 80        )
 81        # Check that positions are changing as expected.
 82        xdiff = np.diff(Ŋ([x[0] for x in positions]))
 83        zdiff = np.diff(Ŋ([x[2] for x in positions]))
 84        assert xdiff[0] > 0
 85        assert zdiff[0] == 0
 86        nt.assert_allclose(xdiff, np.full_like(xdiff, xdiff[0]), rtol=0, atol=1e-10)
 87        nt.assert_allclose(zdiff, np.full_like(zdiff, zdiff[0]), rtol=0, atol=1e-10)
 88        strains_inc = np.zeros_like(timestamps)
 89        for t, time in enumerate(timestamps[:-1], start=1):
 90            strains_inc[t] = strains_inc[t - 1] + (
 91                _utils.strain_increment(timestamps[t] - time, velocity_gradients[t])
 92            )
 93        # fig, ax, _, _ = _vis.pathline_box2d(
 94        #     None,
 95        #     get_velocity,
 96        #     "xz",
 97        #     strains_inc,
 98        #     positions,
 99        #     ".",
100        #     Ŋ([-2e5, -2e5]),
101        #     Ŋ([2e5, 2e5]),
102        #     [20, 20],
103        # )
104        # fig.savefig("/tmp/fig.png")
105        nt.assert_allclose(
106            strains_inc,
107            (timestamps - timestamps[0]) * 1e-15,
108            atol=5e-15,
109            rtol=0,
110        )

Preliminary tests to check that various auxiliary routines are working.

def test_strain_increment(self):
 36    def test_strain_increment(self):
 37        """Test for accumulating strain via strain increment calculations."""
 38        _, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1)
 39        timestamps = np.linspace(0, 1, 10)  # Solve until D₀t=1 (tensorial strain).
 40        strains_inc = np.zeros_like(timestamps)
 41        L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0]))
 42        for i, ε in enumerate(strains_inc[1:]):
 43            strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment(
 44                timestamps[1] - timestamps[0],
 45                L,
 46            )
 47        # For constant timesteps, check strains == positive_timestamps * strain_rate.
 48        nt.assert_allclose(strains_inc, timestamps, atol=6e-16, rtol=0)
 49
 50        # Same thing, but for strain rate similar to experiments.
 51        _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", 1e-5)
 52        timestamps = np.linspace(0, 1e6, 10)  # Solve until D₀t=10 (tensorial strain).
 53        strains_inc = np.zeros_like(timestamps)
 54        L = get_velocity_gradient(np.nan, Ŋ([0e0, 0e0, 0e0]))
 55        for i, ε in enumerate(strains_inc[1:]):
 56            strains_inc[i + 1] = strains_inc[i] + _utils.strain_increment(
 57                timestamps[1] - timestamps[0],
 58                L,
 59            )
 60        nt.assert_allclose(strains_inc, timestamps * 1e-5, atol=5e-15, rtol=0)
 61
 62        # Again, but this time the particle will move (using get_pathline).
 63        # We use a 400km x 400km box and a strain rate of 1e-15 s⁻¹.
 64        get_velocity, get_velocity_gradient = _velocity.simple_shear_2d("X", "Z", 1e-15)
 65        timestamps, get_position = _paths.get_pathline(
 66            Ŋ([1e5, 0e0, 1e5]),
 67            get_velocity,
 68            get_velocity_gradient,
 69            Ŋ([-2e5, 0e0, -2e5]),
 70            Ŋ([2e5, 0e0, 2e5]),
 71            2,
 72            regular_steps=10,
 73        )
 74        positions = [get_position(t) for t in timestamps]
 75        velocity_gradients = [get_velocity_gradient(np.nan, Ŋ(x)) for x in positions]
 76
 77        # Check that polycrystal is experiencing steady velocity gradient.
 78        nt.assert_array_equal(
 79            velocity_gradients, np.full_like(velocity_gradients, velocity_gradients[0])
 80        )
 81        # Check that positions are changing as expected.
 82        xdiff = np.diff(Ŋ([x[0] for x in positions]))
 83        zdiff = np.diff(Ŋ([x[2] for x in positions]))
 84        assert xdiff[0] > 0
 85        assert zdiff[0] == 0
 86        nt.assert_allclose(xdiff, np.full_like(xdiff, xdiff[0]), rtol=0, atol=1e-10)
 87        nt.assert_allclose(zdiff, np.full_like(zdiff, zdiff[0]), rtol=0, atol=1e-10)
 88        strains_inc = np.zeros_like(timestamps)
 89        for t, time in enumerate(timestamps[:-1], start=1):
 90            strains_inc[t] = strains_inc[t - 1] + (
 91                _utils.strain_increment(timestamps[t] - time, velocity_gradients[t])
 92            )
 93        # fig, ax, _, _ = _vis.pathline_box2d(
 94        #     None,
 95        #     get_velocity,
 96        #     "xz",
 97        #     strains_inc,
 98        #     positions,
 99        #     ".",
100        #     Ŋ([-2e5, -2e5]),
101        #     Ŋ([2e5, 2e5]),
102        #     [20, 20],
103        # )
104        # fig.savefig("/tmp/fig.png")
105        nt.assert_allclose(
106            strains_inc,
107            (timestamps - timestamps[0]) * 1e-15,
108            atol=5e-15,
109            rtol=0,
110        )

Test for accumulating strain via strain increment calculations.

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

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):
616    @pytest.mark.slow
617    def test_GBM_calibration(self, outdir, seeds, ncpus):
618        r"""Compare results for various values of $$M^∗$$ to A-type olivine data.
619
620        Velocity gradient:
621        $$
622        \bm{L} = 10^{-4} ×
623            \begin{bmatrix} 0 & 0 & 0 \cr 2 & 0 & 0 \cr 0 & 0 & 0 \end{bmatrix}
624        $$
625
626        Unlike `test_dvdx_GBM`,
627        grain boudary sliding is enabled here (see `_core.DefaultParams`).
628        Data are provided by [Skemer & Hansen, 2016](http://dx.doi.org/10.1016/j.tecto.2015.12.003).
629
630        """
631        shear_direction = Ŋ([0, 1, 0], dtype=np.float64)
632        strain_rate = 1
633        _, get_velocity_gradient = _velocity.simple_shear_2d("Y", "X", strain_rate)
634        timestamps = np.linspace(0, 3.2, 65)  # Solve until D₀t=3.2 ('shear' γ=6.4).
635        params = _core.DefaultParams().as_dict()
636        params["number_of_grains"] = 5000
637        gbm_mobilities = (0, 10, 50, 125)  # Must be in ascending order.
638        markers = ("x", "*", "1", ".")
639        # Uses 100 seeds by default; use all 1000 if you have more RAM and CPU time.
640        _seeds = seeds[:100]
641        n_seeds = len(_seeds)
642        angles = np.empty((len(gbm_mobilities), n_seeds, len(timestamps)))
643        θ_fse = np.zeros_like(timestamps)
644        strains = timestamps * strain_rate
645
646        optional_logging = cl.nullcontext()
647        if outdir is not None:
648            out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_calibration"
649            optional_logging = _io.logfile_enable(f"{out_basepath}.log")
650            labels = []
651
652        with optional_logging:
653            clock_start = process_time()
654            for m, gbm_mobility in enumerate(gbm_mobilities):
655                return_fse = True if m == 0 else False
656                params["gbm_mobility"] = gbm_mobility
657                _run = ft.partial(
658                    self.run,
659                    params,
660                    timestamps,
661                    strain_rate,
662                    get_velocity_gradient,
663                    shear_direction,
664                    return_fse=return_fse,
665                )
666                with Pool(processes=ncpus) as pool:
667                    for s, out in enumerate(pool.imap_unordered(_run, _seeds)):
668                        mineral, fse_angles = out
669                        angles[m, s, :] = [
670                            _diagnostics.smallest_angle(v, shear_direction)
671                            for v in _diagnostics.elasticity_components(
672                                _minerals.voigt_averages([mineral], params)
673                            )["hexagonal_axis"]
674                        ]
675                        # Save the whole mineral for the first seed only.
676                        if outdir is not None and s == 0:
677                            mineral.save(
678                                f"{out_basepath}.npz",
679                                postfix=f"M{_io.stringify(gbm_mobility)}",
680                            )
681                        if return_fse:
682                            θ_fse += fse_angles
683
684                if return_fse:
685                    θ_fse /= n_seeds
686
687                if outdir is not None:
688                    labels.append(f"$M^∗$ = {params['gbm_mobility']}")
689
690            _log.info("elapsed CPU time: %s", np.abs(process_time() - clock_start))
691
692            # Take ensemble means and optionally plot figure.
693            _log.info("postprocessing results...")
694            result_angles = angles.mean(axis=1)
695            result_angles_err = angles.std(axis=1)
696
697            if outdir is not None:
698                schema = {
699                    "delimiter": ",",
700                    "missing": "-",
701                    "fields": [
702                        {
703                            "name": "strain",
704                            "type": "integer",
705                            "unit": "percent",
706                            "fill": 999999,
707                        }
708                    ],
709                }
710                _io.save_scsv(
711                    f"{out_basepath}_strains.scsv",
712                    schema,
713                    [[int(D * 200) for D in strains]],  # Shear strain % is 200 * D₀.
714                )
715                np.savez(
716                    _io.resolve_path(f"{out_basepath}_ensemble_means.npz"),
717                    angles=result_angles,
718                    err=result_angles_err,
719                )
720                fig = _vis.figure(
721                    figsize=(_vis.DEFAULT_FIG_WIDTH * 3, _vis.DEFAULT_FIG_HEIGHT)
722                )
723                fig, ax, colors = _vis.alignment(
724                    fig.add_subplot(),
725                    strains,
726                    result_angles,
727                    markers,
728                    labels,
729                    err=result_angles_err,
730                    θ_max=80,
731                    θ_fse=θ_fse,
732                )
733                (
734                    _,
735                    _,
736                    _,
737                    data_Skemer2016,
738                    indices,
739                ) = _vis.show_Skemer2016_ShearStrainAngles(
740                    ax,
741                    [
742                        "Z&K 1200 C",
743                        "Z&K 1300 C",
744                        "Skemer 2011",
745                        "Hansen 2014",
746                        "Warren 2008",
747                        "Webber 2010",
748                        "H&W 2015",
749                    ],
750                    ["v", "^", "o", "s", "v", "o", "s"],
751                    ["k", "k", "k", "k", "k", "k", "k"],
752                    ["none", "none", "none", "none", None, None, None],
753                    [
754                        "Zhang & Karato, 1995 (1473 K)",
755                        "Zhang & Karato, 1995 (1573 K)",
756                        "Skemer et al., 2011 (1500 K)",
757                        "Hansen et al., 2014 (1473 K)",
758                        "Warren et al., 2008",
759                        "Webber et al., 2010",
760                        "Hansen & Warren, 2015",
761                    ],
762                    fabric=_core.MineralFabric.olivine_A,
763                )
764                _legend = _utils.redraw_legend(ax, loc="upper right", ncols=3)
765                fig.savefig(
766                    _io.resolve_path(f"{out_basepath}.pdf"),
767                    bbox_extra_artists=(_legend,),
768                    bbox_inches="tight",
769                )
770                r2vals = []
771                for angles in result_angles:
772                    _angles = PchipInterpolator(strains, angles)
773                    r2 = np.sum(
774                        [
775                            (a - b) ** 2
776                            for a, b in zip(
777                                _angles(
778                                    np.take(data_Skemer2016.shear_strain, indices) / 200
779                                ),
780                                np.take(data_Skemer2016.angle, indices),
781                            )
782                        ]
783                    )
784                    r2vals.append(r2)
785                _log.info(
786                    "Sums of squared residuals (r-values) for each M∗: %s", r2vals
787                )

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):
789    @pytest.mark.big
790    def test_dudz_pathline(self, outdir, seed):
791        """Test alignment of olivine a-axis for a polycrystal advected on a pathline."""
792        test_id = "dudz_pathline"
793        optional_logging = cl.nullcontext()
794        if outdir is not None:
795            out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}"
796            optional_logging = _io.logfile_enable(f"{out_basepath}.log")
797
798        with optional_logging:
799            shear_direction = Ŋ([1, 0, 0], dtype=np.float64)
800            strain_rate = 1e-15  # Moderate, realistic shear in the upper mantle.
801            get_velocity, get_velocity_gradient = _velocity.simple_shear_2d(
802                "X", "Z", strain_rate
803            )
804            n_timesteps = 10
805            timestamps, get_position = _paths.get_pathline(
806                Ŋ([1e5, 0e0, 1e5]),
807                get_velocity,
808                get_velocity_gradient,
809                Ŋ([-2e5, 0e0, -2e5]),
810                Ŋ([2e5, 0e0, 2e5]),
811                2,
812                regular_steps=n_timesteps,
813            )
814            positions = [get_position(t) for t in timestamps]
815            velocity_gradients = [
816                get_velocity_gradient(np.nan, Ŋ(x)) for x in positions
817            ]
818
819            params = _core.DefaultParams().as_dict()
820            params["gbm_mobility"] = 10
821            params["number_of_grains"] = 5000
822            olA = _minerals.Mineral(n_grains=params["number_of_grains"], seed=seed)
823            deformation_gradient = np.eye(3)
824            strains = np.zeros_like(timestamps)
825            for t, time in enumerate(timestamps[:-1], start=1):
826                strains[t] = strains[t - 1] + (
827                    _utils.strain_increment(timestamps[t] - time, velocity_gradients[t])
828                )
829                _log.info("step %d/%d (ε = %.2f)", t, len(timestamps) - 1, strains[t])
830                deformation_gradient = olA.update_orientations(
831                    params,
832                    deformation_gradient,
833                    get_velocity_gradient,
834                    pathline=(time, timestamps[t], get_position),
835                )
836
837            if outdir is not None:
838                olA.save(f"{out_basepath}.npz")
839
840            orient_resampled, fractions_resampled = _stats.resample_orientations(
841                olA.orientations, olA.fractions, seed=seed
842            )
843            # About 36GB, 26 min needed with float64. GitHub macos runner has 14GB.
844            misorient_indices = _diagnostics.misorientation_indices(
845                orient_resampled,
846                _geo.LatticeSystem.orthorhombic,
847                ncpus=3,
848            )
849            cpo_vectors = np.zeros((n_timesteps + 1, 3))
850            cpo_angles = np.zeros(n_timesteps + 1)
851            for i, matrices in enumerate(orient_resampled):
852                cpo_vectors[i] = _diagnostics.bingham_average(
853                    matrices,
854                    axis=_minerals.OLIVINE_PRIMARY_AXIS[olA.fabric],
855                )
856                cpo_angles[i] = _diagnostics.smallest_angle(
857                    cpo_vectors[i], shear_direction
858                )
859
860            # Check for mostly decreasing CPO angles (exclude initial condition).
861            _log.debug("cpo angles: %s", cpo_angles)
862            nt.assert_array_less(np.diff(cpo_angles[1:]), np.ones(n_timesteps - 1))
863            # Check for increasing CPO strength (M-index).
864            _log.debug("cpo strengths: %s", misorient_indices)
865            nt.assert_array_less(
866                np.full(n_timesteps, -0.01), np.diff(misorient_indices)
867            )
868            # Check that last angle is <5° (M*=125) or <10° (M*=10).
869            assert cpo_angles[-1] < 10
870
871        if outdir is not None:
872            fig, ax, _, _ = _vis.steady_box2d(
873                None,
874                (get_velocity, [20, 20]),
875                (positions, Ŋ([-2e5, -2e5]), Ŋ([2e5, 2e5])),
876                "xz",
877                (cpo_vectors, misorient_indices),
878                strains,
879            )
880            fig.savefig(f"{out_basepath}.pdf")

Test alignment of olivine a-axis for a polycrystal advected on a pathline.