예제 #1
0
    def test_outer_shape(self):
        """
        Test assigning `ndim_outer` != 0 is implemented correctly.

        """
        # Outer shape (shared by axes and angles):
        out_shp = (3, 2)

        axes_multiple = np.random.random(out_shp + (3, ))
        ang_multiple = (np.random.random(out_shp) - 0.5) * 180
        params_multiple = {
            'rot_ax': axes_multiple,
            'ang': ang_multiple,
            'axis': -1,
            'ndim_outer': len(out_shp),
            'degrees': True,
        }
        params_single = {
            'rot_ax': axes_multiple[0, 0],
            'ang': ang_multiple[0, 0],
            'axis': -1,
            'ndim_outer': 0,
            'degrees': True,
        }
        rot_mat_mult = rotation.axang2rotmat(**params_multiple)
        rot_mat_sing = rotation.axang2rotmat(**params_single)

        self.assertTrue(np.allclose(rot_mat_mult[0, 0], rot_mat_sing))
예제 #2
0
    def test_degrees_to_radians(self):
        """
        Test identical rotation matrices are generated whether angles are
        input in degrees or radians.

        """
        axis = np.random.random((3, ))
        ang_deg = np.random.random() * 180
        ang_rad = np.deg2rad(ang_deg)
        rot_deg = rotation.axang2rotmat(axis, ang_deg, degrees=True)
        rot_rad = rotation.axang2rotmat(axis, ang_rad, degrees=False)
        self.assertTrue(np.allclose(rot_deg, rot_rad))
예제 #3
0
def get_load_case_random_3D(total_time,
                            num_increments,
                            target_strain,
                            rotation=True,
                            rotation_max_angle=10,
                            rotation_load_case=True,
                            non_random_rotation=None,
                            dump_frequency=1):

    # Five stretch components, since it's a symmetric matrix and the trace must be zero:
    stretch_comps = (np.random.random((5, )) - 0.5) * target_strain
    stretch = np.zeros((3, 3)) * np.nan

    # Diagonal comps:
    stretch[[0, 1], [0, 1]] = stretch_comps[:2]
    stretch[2, 2] = -(stretch[0, 0] + stretch[1, 1])

    # Off-diagonal comps:
    stretch[[1, 0], [0, 1]] = stretch_comps[2]
    stretch[[2, 0], [0, 2]] = stretch_comps[3]
    stretch[[1, 2], [2, 1]] = stretch_comps[4]

    # Add the identity:
    U = stretch + np.eye(3)

    defgrad = U
    rot = None
    if rotation and non_random_rotation is None:
        rot = get_random_rotation_matrix(method='axis_angle',
                                         max_angle_deg=rotation_max_angle)
        if not rotation_load_case:
            defgrad = rot @ U
            rot = None

    if non_random_rotation:
        rot = axang2rotmat(np.array(non_random_rotation['axis']),
                           non_random_rotation['angle_deg'],
                           degrees=True)

    # Ensure defgrad has a unit determinant:
    defgrad = defgrad / (np.linalg.det(defgrad)**(1 / 3))

    dg_arr = np.ma.masked_array(defgrad, mask=np.zeros((3, 3), dtype=int))
    stress_arr = np.ma.masked_array(np.zeros((3, 3), dtype=int),
                                    mask=np.ones((3, 3), dtype=int))

    load_case = {
        'total_time': total_time,
        'num_increments': num_increments,
        'def_grad_aim': dg_arr,
        'stress': stress_arr,
        'rotation': rot,
        'dump_frequency': dump_frequency,
    }

    return load_case
예제 #4
0
    def test_broadcast_axes(self):
        """
        Test a single axis can be broadcast correctly over multiple angles.

        """

        params_multiple = {
            'rot_ax': np.random.random((3, )),
            'ang': (np.random.random((2, )) - 0.5) * 180,
            'axis': 0,
            'ndim_outer': 0,
            'degrees': True,
        }
        params_single = copy.deepcopy(params_multiple)
        params_single['ang'] = params_single['ang'][0]

        rot_mat_mult = rotation.axang2rotmat(**params_multiple)
        rot_mat_sing = rotation.axang2rotmat(**params_single)

        self.assertTrue(np.allclose(rot_mat_mult[0], rot_mat_sing))
예제 #5
0
    def test_zero_rotation(self):
        """
        Test a rotation about a random axis by zero degrees generates the
        identity matrix.

        """
        params = {
            'rot_ax': np.random.random((3, )),
            'ang': 0,
            'axis': 0,
            'ndim_outer': 0,
            'degrees': True,
        }
        rot_mat = rotation.axang2rotmat(**params)
        self.assertTrue(np.allclose(rot_mat, np.eye(3)))
예제 #6
0
    def test_known_rotation(self):
        """
        Test the generated rotation matrix for a known rotation (90 degrees
        about the z-axis) is correct.

        """
        axis = np.array([0, 0, 1])
        ang = np.pi / 2
        rot = rotation.axang2rotmat(axis, ang)
        known_rot = np.array([
            [0, -1, 0],
            [1, 0, 0],
            [0, 0, 1],
        ])
        self.assertTrue(np.allclose(rot, known_rot))
예제 #7
0
    def test_always_positive_angle(self):
        """
        Test conversion from axis-angle -> matrix -> axis-angle with negative
        angle flips the axis and angle signs, so the output angle is positive.

        """
        axis_1 = np.random.random((3, 1))
        axis_1_norm = axis_1 / np.linalg.norm(axis_1, axis=0)
        ang_1 = (np.random.random() - 1) * 180
        params = {
            'rot_ax': axis_1,
            'ang': ang_1,
            'axis': 0,
            'ndim_outer': 0,
            'degrees': True,
        }
        rot_mat = rotation.axang2rotmat(**params)
        axis_2, ang_2 = rotation.rotmat2axang(rot_mat, degrees=True)
        axis_2_norm = axis_2 / np.linalg.norm(axis_2, axis=0)

        axis_equal = np.allclose(axis_1_norm, -axis_2_norm)
        angle_equal = np.isclose(ang_1, -ang_2)

        self.assertTrue(axis_equal and angle_equal)
예제 #8
0
    def test_axis_angle_consistent(self):
        """
        Test conversion from axis-angle -> matrix -> axis-angle with positive
        angles are consistent.

        """
        axis_1 = np.random.random((3, 1))
        axis_1_norm = axis_1 / np.linalg.norm(axis_1, axis=0)
        ang_1 = np.random.random() * 180
        params = {
            'rot_ax': axis_1,
            'ang': ang_1,
            'axis': 0,
            'ndim_outer': 0,
            'degrees': True,
        }
        rot_mat = rotation.axang2rotmat(**params)
        axis_2, ang_2 = rotation.rotmat2axang(rot_mat, degrees=True)
        axis_2_norm = axis_2 / np.linalg.norm(axis_2, axis=0)

        axis_equal = np.allclose(axis_1_norm, axis_2_norm)
        angle_equal = np.isclose(ang_1, ang_2)

        self.assertTrue(axis_equal and angle_equal)
예제 #9
0
def bicrystal_from_csl_vectors(crystal_structure,
                               csl_vecs,
                               box_csl=None,
                               gb_type=None,
                               gb_size=None,
                               edge_conditions=None,
                               overlap_tol=None,
                               reorient=True,
                               wrap=True,
                               maintain_inv_sym=False,
                               boundary_vac=None,
                               relative_shift=None):
    """
    Parameters
    ----------
    crystal_structure : dict or CrystalStructure
    csl_vecs : list of length 2 of ndarray of shape (3, 3)
        List of two arrays of three column vectors representing CSL vectors
        in the lattice basis. The two CSL unit cells defined here rotate onto
        each other by the CSL rotation angle. The rotation axis is taken as the
        third vector, which must therefore be the same for both CSL unit cells.
    box_csl : ndarray of shape (3, 3), optional
        The linear combination of CSL unit vectors defined in `csl_vecs` used
        to construct each half of the bicrystal. The first two columns
        represent vectors defining the boundary plane. The third column
        represents a vector in the out-of-boundary direction. Only one of
        `box_csl` and `gb_type` may be specified.
    gb_type : str, optional
        Default is None. Must be one of 'tilt_A', 'tilt_B', 'twist' or
        'mixed_A'. Only one of `box_csl` and `gb_type` may be specified.
    gb_size : ndarray of shape (3,) of int, optional
        If `gb_type` is specified, the unit grain vectors associated with that
        `gb_type` are scaled by these integers. Default is None, in which case
        it is set to np.array([1, 1, 1]).
    edge_conditions : list of list of str
        Edge conditions for each grain in the bicrystal. See `CrystalBox` for
        details.
    maintain_inv_sym : bool, optional
        If True, the supercell atoms will be checked for inversion symmetry
        through the centres of both crystals. This check will be repeated
        following methods which manipulate atom positions. In this way, the two
        grain boundaries in the bicrystal are ensured to be identical.
    reorient : bool, optional
        If True, after construction of the boundary, reorient_to_lammps() is
        invoked. Default is True.
    boundary_vac_args : dict, optional
        If not None, after construction of the boundary, apply_boundary_vac()
        is invoked with this dict as keyword arguments. Default is None.
    boundary_vac_flat_args : dict, optional
        If not None, after construction of the boundary,
        apply_boundary_vac_flat() is invoked with this dict as keyword
        arguments. Default is None.
    boundary_vac_linear_args : dict, optional
        If not None, after construction of the boundary,
        apply_boundary_vac_linear() is invoked with this dict as keyword
        arguments. Default is None.
    relative_shift_args : dict, optional
        If not None, after construction of the boundary, apply_relative_shift()
        is invoked with this dict as keyword arguments. Default is None.
    wrap : bool, optional
        If True, after construction of the boundary, wrap_atoms_to_supercell()
        is invoked. Default is True.

    Notes
    -----
    Algorithm proceeds as follows:
    1.  Apply given linear combinations of given CSL unit vectors to form grain
        vectors of the bicrystal.
    2.  Multiply the out-of-boundary vector of the second grain by -1, such
        that rotation of the second grain by the CSL rotation angle will form a
        bicrystal of two grains.
    3.  Check grain A is formed from a right-hand basis - since we want the
        supercell vectors to be formed from a right-hand basis. If not, for
        both grain A and B, swap the first and second vectors to do this.
    4.  Fill the two grains with atoms
    5.  Rotate B onto A??

    TODO:
    -   Sort out lattice sites in apply_boundary_vac() & apply_relative_shift()
    -   Rename wrap_atoms_to_supercell to wrap_to_supercell and apply wrapping
        to lattice sites, and crystal boxes as well.

    """

    # Generate CrystalStructure if necessary:
    cs = CrystalStructure.init_crystal_structures([crystal_structure])[0]
    box_csl = np.array(box_csl)
    csl_vecs = np.array(csl_vecs)

    # print('csl_vecs: {}'.format(csl_vecs))

    if np.all(csl_vecs[0][:, 2] != csl_vecs[1][:, 2]):
        raise ValueError('Third vectors in `csl_vecs[0]` and csl_vecs[1] '
                         'represent the CSL rotation axis and must '
                         'therefore be equal.')

    if box_csl is not None and gb_type is not None:
        raise ValueError('Only one of `box_csl` and `gb_type` may be '
                         'specified.')

    if box_csl is None and gb_type is None:
        raise ValueError('Exactly one of `box_csl` and `gb_type` must be '
                         'specified.')

    if gb_type is not None:

        if gb_size is None:
            gb_size = np.array([1, 1, 1])

        if gb_type not in CSL_FROM_PARAMS_GB_TYPES:
            raise ValueError('Invalid `gb_type`: {}. Must be one of {}'.format(
                gb_type, list(CSL_FROM_PARAMS_GB_TYPES.keys())))

        box_csl = CSL_FROM_PARAMS_GB_TYPES.get(gb_type) * gb_size

    lat_vecs = cs.lattice.unit_cell
    rot_ax_std = np.dot(lat_vecs, csl_vecs[0][:, 2:3])
    csl_vecs_std = [np.dot(lat_vecs, c) for c in csl_vecs]

    # Non-boundary (column) index of `box_csl` and grain arrays:
    non_boundary_idx = 2

    # Enforce a rule that out of boundary grain vector has to be
    # (a multiple of) a single CSL unit vector. This reduces the
    # potential "skewness" of the supercell.
    if np.count_nonzero(box_csl[:, non_boundary_idx]) > 1:
        raise ValueError('The out of boundary vector, `box_csl[:, {}]`'
                         ' must have exactly one non-zero '
                         'element.'.format(non_boundary_idx))

    # Scale grains in lattice basis
    grn_a_lat = np.dot(csl_vecs[0], box_csl)
    grn_b_lat = np.dot(csl_vecs[1], box_csl)
    grn_b_lat[:, non_boundary_idx] *= -1

    # Get grain vectors in standard Cartesian basis
    grn_a_std = np.dot(lat_vecs, grn_a_lat)
    grn_b_std = np.dot(lat_vecs, grn_b_lat)

    # Get rotation matrix for rotating grain B onto grain A
    if np.all(csl_vecs[0] == csl_vecs[1]):
        rot_angles = [0, 0, 0]
        rot_mat = np.eye(3)

    else:
        rot_angles = vecpair_angle(*csl_vecs_std, axis=0)
        # print('rot_angles: {}'.format(np.rad2deg(rot_angles)))
        # print('rot_ax_std: {}'.format(rot_ax_std))
        if not np.isclose(*rot_angles[0:2]):
            raise ValueError('Non-equivalent rotation angles found between CSL'
                             ' vectors.')

        rot_mat = axang2rotmat(rot_ax_std[:, 0], rot_angles[0])

    grn_vols = [
        np.dot(np.cross(g[:, 0], g[:, 1]), g[:, 2])
        for g in (grn_a_std, grn_b_std)
    ]

    # print('grn_vols: {}'.format(grn_vols))

    # Check grain volumes are the same:
    if not np.isclose(*np.abs(grn_vols)):
        raise ValueError('Grain A and B have different volumes.')

    # Check if grain A forms a right-handed coordinate system:
    if grn_vols[0] < 0:
        # Swap boundary vectors to make a right-handed coordinate system:
        grn_a_lat[:, [0, 1]] = grn_a_lat[:, [1, 0]]
        grn_b_lat[:, [0, 1]] = grn_b_lat[:, [1, 0]]
        grn_a_std[:, [0, 1]] = grn_a_std[:, [1, 0]]
        grn_b_std[:, [0, 1]] = grn_b_std[:, [1, 0]]
        box_csl[0, 1] = box_csl[1, 0]

    # Specify bounding box edge conditions for including atoms:
    if edge_conditions is None:
        edge_conditions = [['10', '10', '10'], ['10', '10', '10']]
        edge_conditions[1][non_boundary_idx] = '01'

    # print('grn_a_std: \n{}'.format(grn_a_std))
    # print('grn_b_std: \n{}'.format(grn_b_std))

    # Make two crystal boxes:
    crys_a = CrystalBox(cs, grn_a_std, edge_conditions=edge_conditions[0])
    crys_b = CrystalBox(cs, grn_b_std, edge_conditions=edge_conditions[1])

    for sites in list(crys_a.sites.values()) + list(crys_b.sites.values()):
        sites.basis = None

    # Rotate crystal B onto A:
    crys_b.rotate(rot_mat)

    # Shift crystals to form a supercell at the origin
    zero_shift = -crys_b.box_vecs[:, non_boundary_idx][:, None]
    crys_a.translate(zero_shift)
    crys_b.translate(zero_shift)

    # Define the supercell:
    sup_std = np.copy(crys_a.box_vecs)
    sup_std[:, non_boundary_idx] = (crys_a.box_vecs[:, non_boundary_idx] -
                                    crys_b.box_vecs[:, non_boundary_idx])

    # AtomisticStructure parameters
    as_params = {
        'supercell': sup_std,
        'crystals': [crys_a, crys_b],
    }

    # Bicrystal parameters
    bc_params = {
        'as_params': as_params,
        'maintain_inv_sym': maintain_inv_sym,
        'reorient': reorient,
        'boundary_vac': boundary_vac,
        'relative_shift': relative_shift,
        'wrap': wrap,
        'non_boundary_idx': 2,
        'rot_mat': rot_mat,
        'overlap_tol': overlap_tol,
    }

    return Bicrystal(**bc_params)
예제 #10
0
def get_load_case_planar_2D(total_time,
                            num_increments,
                            normal_direction,
                            target_strain_rate=None,
                            target_strain=None,
                            rotation=None,
                            dump_frequency=1):
    """Get a planar 2D load case.

    Parameters
    ----------
    total_time : float or int
    num_increments : int
    normal_direction : str
        A single character, "x", "y" or "z", representing the loading plane normal
        direction.
    target_strain_rate : (nested) list of float or ndarray of shape (2, 2)
        Target deformation gradient rate components. Either a 2D array, nested list, or a
        flat list. If passed as a flat list, the first and fourth elements correspond to
        the normal components of the deformation gradient rate tensor. The second element
        corresponds to the first-row, second-column (shear) component and the third
        element corresponds to the second-row, first-column (shear) component.
    target_strain : (nested) list of float or ndarray of shape (2, 2)
        Target deformation gradient components. Either a 2D array, nested list, or a
        flat list. If passed as a flat list, the first and fourth elements correspond to
        the normal components of the deformation gradient tensor. The second element
        corresponds to the first-row, second-column (shear) component and the third
        element corresponds to the second-row, first-column (shear) component.
    rotation : dict, optional
        Dict to specify a rotation of the loading direction. With keys:
            axis : ndarray of shape (3) or list of length 3
                Axis of rotation.
            angle_deg : float
                Angle of rotation about `axis` in degrees.
    dump_frequency : int, optional
        By default, 1, meaning results are written out every increment.

    Returns
    -------
    dict
        Dict representing the load case, with keys:
            normal_direction : str
                Passed through from input argument.
            rotation : dict
                Passed through from input argument.            
            total_time : float or int
                Passed through from input argument.
            num_increments : int
                Passed through from input argument.
            rotation_matrix : ndarray of shape (3, 3), optional
                If `rotation` was specified, this will be the matrix representation of
                `rotation`.
            def_grad_rate : numpy.ma.core.MaskedArray of shape (3, 3), optional
                Deformation gradient rate tensor. Not None if target_strain_rate is 
                specified. Masked values correspond to unmasked values in `stress`.
            def_grad_aim : numpy.ma.core.MaskedArray of shape (3, 3), optional
                Deformation gradient aim tensor. Not None if target_strain is specified.
                Masked values correspond to unmasked values in `stress`.
            stress : numpy.ma.core.MaskedArray of shape (3, 3)
                Stress tensor. Masked values correspond to unmasked values in
                `def_grad_rate` or `def_grad_aim`.
            dump_frequency : int, optional
                Passed through from input argument.

    """

    # Validation:
    msg = 'Specify either `target_strain_rate` or `target_strain`.'
    if all([t is None for t in [target_strain_rate, target_strain]]):
        raise ValueError(msg)
    if all([t is not None for t in [target_strain_rate, target_strain]]):
        raise ValueError(msg)

    if rotation:
        rot_mat = axang2rotmat(np.array(rotation['axis']),
                               rotation['angle_deg'],
                               degrees=True)
    else:
        rot_mat = None

    if target_strain_rate is not None:
        def_grad_vals = target_strain_rate
    else:
        def_grad_vals = target_strain

    # Flatten list/array:
    if isinstance(def_grad_vals, list):
        if isinstance(def_grad_vals[0], list):
            def_grad_vals = [j for i in def_grad_vals for j in i]
    elif isinstance(def_grad_vals, np.ndarray):
        def_grad_vals = def_grad_vals.flatten()

    dir_idx = ['x', 'y', 'z']
    try:
        normal_dir_idx = dir_idx.index(normal_direction)
    except ValueError:
        msg = (
            f'Normal direction "{normal_direction}" not allowed. It should be one of '
            f'"x", "y" or "z".')
        raise ValueError(msg)

    loading_col_idx = list({0, 1, 2} - {normal_dir_idx})
    dg_arr = np.ma.masked_array(np.zeros((3, 3)), mask=np.zeros((3, 3)))
    stress_arr = np.ma.masked_array(np.zeros((3, 3)), mask=np.zeros((3, 3)))

    dg_row_idx = [
        loading_col_idx[0],
        loading_col_idx[0],
        loading_col_idx[1],
        loading_col_idx[1],
    ]
    dg_col_idx = [
        loading_col_idx[0],
        loading_col_idx[1],
        loading_col_idx[0],
        loading_col_idx[1],
    ]
    dg_arr[dg_row_idx, dg_col_idx] = def_grad_vals
    dg_arr.mask[:, normal_dir_idx] = True
    stress_arr.mask[:, loading_col_idx] = True

    def_grad_aim = dg_arr if target_strain is not None else None
    def_grad_rate = dg_arr if target_strain_rate is not None else None

    load_case = {
        'normal_direction': normal_direction,
        'rotation': rotation,
        'total_time': total_time,
        'num_increments': num_increments,
        'rotation_matrix': rot_mat,
        'def_grad_rate': def_grad_rate,
        'def_grad_aim': def_grad_aim,
        'stress': stress_arr,
        'dump_frequency': dump_frequency,
    }

    return check_load_case(load_case)
예제 #11
0
def get_load_case_uniaxial(total_time,
                           num_increments,
                           direction,
                           target_strain_rate=None,
                           target_strain=None,
                           rotation=None,
                           dump_frequency=1):
    """Get a uniaxial load case.

    Parameters
    ----------
    total_time : float or int
    num_increments : int
    direction : str
        Either a single character, "x", "y" or "z", representing the loading direction.
    target_strain_rate : float
        Target strain rate to apply along the loading direction.
    target_strain : float
        Target strain to achieve along the loading direction.
    rotation : dict, optional
        Dict to specify a rotation of the loading direction. With keys:
            axis : ndarray of shape (3) or list of length 3
                Axis of rotation.
            angle_deg : float
                Angle of rotation about `axis` in degrees.
    dump_frequency : int, optional
        By default, 1, meaning results are written out every increment.

    Returns
    -------
    dict
        Dict representing the load case, with keys:
            direction : str
                Passed through from input argument.
            rotation : dict
                Passed through from input argument.            
            total_time : float or int
                Passed through from input argument.
            num_increments : int
                Passed through from input argument.
            rotation_matrix : ndarray of shape (3, 3), optional
                If `rotation` was specified, this will be the matrix representation of
                `rotation`.
            def_grad_rate : numpy.ma.core.MaskedArray of shape (3, 3), optional
                Deformation gradient rate tensor. Not None if target_strain_rate is 
                specified. Masked values correspond to unmasked values in `stress`.
            def_grad_aim : numpy.ma.core.MaskedArray of shape (3, 3), optional
                Deformation gradient aim tensor. Not None if target_strain is specified.
                Masked values correspond to unmasked values in `stress`.
            stress : numpy.ma.core.MaskedArray of shape (3, 3)
                Stress tensor. Masked values correspond to unmasked values in
                `def_grad_rate` or `def_grad_aim`.
            dump_frequency : int, optional
                Passed through from input argument.

    """

    # Validation:
    msg = 'Specify either `target_strain_rate` or `target_strain`.'
    if all([t is None for t in [target_strain_rate, target_strain]]):
        raise ValueError(msg)
    if all([t is not None for t in [target_strain_rate, target_strain]]):
        raise ValueError(msg)

    if rotation:
        rot_mat = axang2rotmat(np.array(rotation['axis']),
                               rotation['angle_deg'],
                               degrees=True)
    else:
        rot_mat = None

    if target_strain_rate is not None:
        def_grad_val = target_strain_rate
    else:
        def_grad_val = target_strain

    dir_idx = ['x', 'y', 'z']
    try:
        loading_dir_idx = dir_idx.index(direction)
    except ValueError:
        msg = (
            f'Loading direction "{direction}" not allowed. It should be one of "x", '
            f'"y" or "z".')
        raise ValueError(msg)

    dg_arr = np.ma.masked_array(np.zeros((3, 3)), mask=np.eye(3))
    stress_arr = np.ma.masked_array(np.zeros((3, 3)),
                                    mask=np.logical_not(np.eye(3)))

    dg_arr[loading_dir_idx, loading_dir_idx] = def_grad_val
    dg_arr.mask[loading_dir_idx, loading_dir_idx] = False
    stress_arr.mask[loading_dir_idx, loading_dir_idx] = True

    def_grad_aim = dg_arr if target_strain is not None else None
    def_grad_rate = dg_arr if target_strain_rate is not None else None

    load_case = {
        'direction': direction,
        'rotation': rotation,
        'total_time': total_time,
        'num_increments': num_increments,
        'rotation_matrix': rot_mat,
        'def_grad_rate': def_grad_rate,
        'def_grad_aim': def_grad_aim,
        'stress': stress_arr,
        'dump_frequency': dump_frequency,
    }

    return check_load_case(load_case)
예제 #12
0
def get_load_case_plane_strain(total_time,
                               num_increments,
                               direction,
                               target_strain_rate=None,
                               target_strain=None,
                               rotation=None,
                               dump_frequency=1,
                               strain_rate_mode=None):
    """Get a plane-strain load case.

    Parameters
    ----------
    total_time : float or int
    num_increments : int
    direction : str
        String of two characters, ij, where {i,j} ∈ {"x","y","z"}. The first character, i,
        corresponds to the loading direction and the second, j, corresponds to the
        zero-strain direction. Zero stress will be specified on the remaining direction.
    target_strain_rate : float
        Target strain rate to apply along the loading direction.
    target_strain : float
        Target strain to achieve along the loading direction.
    rotation : dict, optional
        Dict to specify a rotation of the loading direction. With keys:
            axis : ndarray of shape (3) or list of length 3
                Axis of rotation.
            angle_deg : float
                Angle of rotation about `axis` in degrees.        
    dump_frequency : int, optional
        By default, 1, meaning results are written out every increment.
    strain_rate_mode : str, optional
        One of "F_rate", "L", "L_approx". If not specified, default is "F_rate". Use
        "L_approx" for specifying non-mixed boundary conditions.

    Returns
    -------
    dict
        Dict representing the load case, with keys:
            direction : str
                Passed through from input argument.
            rotation : dict
                Passed through from input argument.            
            total_time : float or int
                Passed through from input argument.
            num_increments : int
                Passed through from input argument.
            rotation_matrix : ndarray of shape (3, 3), optional
                If `rotation` was specified, this will be the matrix representation of
                `rotation`.
            def_grad_rate : numpy.ma.core.MaskedArray of shape (3, 3), optional
                Deformation gradient rate tensor. Not None if target_strain_rate is 
                specified and `strain_rate_mode` is None or "F_rate". Masked values
                correspond to unmasked values in `stress`.
            def_grad_aim : numpy.ma.core.MaskedArray of shape (3, 3), optional
                Deformation gradient aim tensor. Not None if target_strain is specified.
                Masked values correspond to unmasked values in `stress`.
            vel_grad : (ndarray or numpy.ma.core.MaskedArray) of shape (3, 3), optional
                Velocity gradient aim tensor. Not None if `strain_rate_mode` is one of "L"
                (will be a masked array) or "L_approx" (will be an ordinary array).
            stress : numpy.ma.core.MaskedArray of shape (3, 3)
                Stress tensor. Masked values correspond to unmasked values in
                `def_grad_rate` or `def_grad_aim`.
            dump_frequency : int, optional
                Passed through from input argument.

    """

    # Validation:
    msg = 'Specify either `target_strain_rate` or `target_strain`.'
    if all([t is None for t in [target_strain_rate, target_strain]]):
        raise ValueError(msg)
    if all([t is not None for t in [target_strain_rate, target_strain]]):
        raise ValueError(msg)

    if strain_rate_mode is None:
        strain_rate_mode = 'F_rate'
    if strain_rate_mode not in ['F_rate', 'L', 'L_approx']:
        msg = 'Strain rate mode must be `F_rate`, `L` or `L_approx`.'
        raise ValueError(msg)
    if strain_rate_mode in ['L', 'L_approx'] and target_strain_rate is None:
        msg = (f'`target_strain_rate` must be specified for `strain_rate_mode`'
               f'`{strain_rate_mode}`')
        raise ValueError(msg)

    if rotation:
        rot_mat = axang2rotmat(np.array(rotation['axis']),
                               rotation['angle_deg'],
                               degrees=True)
    else:
        rot_mat = None

    if target_strain_rate is not None:
        def_grad_val = target_strain_rate
    else:
        def_grad_val = target_strain

    dir_idx = ['x', 'y', 'z']
    loading_dir, zero_strain_dir = direction
    try:
        loading_dir_idx = dir_idx.index(loading_dir)
    except ValueError:
        msg = (
            f'Loading direction "{loading_dir}" not allowed. It should be one of "x", '
            f'"y" or "z".')
        raise ValueError(msg)

    if zero_strain_dir not in dir_idx:
        msg = (
            f'Zero-strain direction "{zero_strain_dir}" not allowed. It should be one '
            f'of "x", "y" or "z".')
        raise ValueError(msg)

    zero_stress_dir = list(set(dir_idx) - {loading_dir, zero_strain_dir})[0]
    zero_stress_dir_idx = dir_idx.index(zero_stress_dir)

    dg_arr = np.ma.masked_array(np.zeros((3, 3)), mask=np.zeros((3, 3)))
    stress_arr = np.ma.masked_array(np.zeros((3, 3)), mask=np.ones((3, 3)))

    dg_arr[loading_dir_idx, loading_dir_idx] = def_grad_val

    if strain_rate_mode == 'L':
        # When using L with mixed BCs, each row must be either L or P:
        dg_arr.mask[zero_stress_dir_idx] = True
        stress_arr.mask[zero_stress_dir_idx] = False

    elif strain_rate_mode == 'L_approx':
        dg_arr = dg_arr.data  # No need for a masked array
        # Without mixed BCs, we can get volume conservation with Trace(L) = 0:
        dg_arr[zero_stress_dir_idx, zero_stress_dir_idx] = -def_grad_val
        stress_arr = None

    elif strain_rate_mode == 'F_rate':
        dg_arr.mask[zero_stress_dir_idx, zero_stress_dir_idx] = True
        stress_arr.mask[zero_stress_dir_idx, zero_stress_dir_idx] = False

    if strain_rate_mode in ['L', 'L_approx']:
        def_grad_aim = None
        def_grad_rate = None
        vel_grad = dg_arr
    else:
        def_grad_aim = dg_arr if target_strain is not None else None
        def_grad_rate = dg_arr if target_strain_rate is not None else None
        vel_grad = None

    load_case = {
        'direction': direction,
        'rotation': rotation,
        'total_time': total_time,
        'num_increments': num_increments,
        'rotation_matrix': rot_mat,
        'def_grad_rate': def_grad_rate,
        'def_grad_aim': def_grad_aim,
        'vel_grad': vel_grad,
        'stress': stress_arr,
        'dump_frequency': dump_frequency,
    }

    return check_load_case(load_case)