• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

materialsproject / pymatgen / 4075885785

pending completion
4075885785

push

github

Shyue Ping Ong
Merge branch 'master' of github.com:materialsproject/pymatgen

96 of 96 new or added lines in 27 files covered. (100.0%)

81013 of 102710 relevant lines covered (78.88%)

0.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.55
/pymatgen/analysis/magnetism/analyzer.py
1
# Copyright (c) Pymatgen Development Team.
2
# Distributed under the terms of the MIT License.
3

4
"""
1✔
5
This module provides some useful functions for dealing with magnetic Structures
6
(e.g. Structures with associated magmom tags).
7
"""
8

9
from __future__ import annotations
1✔
10

11
import logging
1✔
12
import os
1✔
13
import warnings
1✔
14
from collections import namedtuple
1✔
15
from enum import Enum, unique
1✔
16
from typing import Any
1✔
17

18
import numpy as np
1✔
19
from monty.serialization import loadfn
1✔
20
from scipy.signal import argrelextrema
1✔
21
from scipy.stats import gaussian_kde
1✔
22

23
from pymatgen.core.structure import DummySpecies, Element, Species, Structure
1✔
24
from pymatgen.electronic_structure.core import Magmom
1✔
25
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
1✔
26
from pymatgen.symmetry.groups import SpaceGroup
1✔
27
from pymatgen.transformations.advanced_transformations import (
1✔
28
    MagOrderingTransformation,
29
    MagOrderParameterConstraint,
30
)
31
from pymatgen.transformations.standard_transformations import (
1✔
32
    AutoOxiStateDecorationTransformation,
33
)
34
from pymatgen.util.typing import VectorLike
1✔
35

36
__author__ = "Matthew Horton"
1✔
37
__copyright__ = "Copyright 2017, The Materials Project"
1✔
38
__version__ = "0.1"
1✔
39
__maintainer__ = "Matthew Horton"
1✔
40
__email__ = "mkhorton@lbl.gov"
1✔
41
__status__ = "Development"
1✔
42
__date__ = "Feb 2017"
1✔
43

44
MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
1✔
45

46
try:
1✔
47
    DEFAULT_MAGMOMS = loadfn(os.path.join(MODULE_DIR, "default_magmoms.yaml"))
1✔
48
except Exception:
×
49
    warnings.warn("Could not load default_magmoms.yaml, falling back to VASPIncarBase.yaml")
×
50
    DEFAULT_MAGMOMS = loadfn(os.path.join(MODULE_DIR, "../../io/vasp/VASPIncarBase.yaml"))
×
51
    DEFAULT_MAGMOMS = DEFAULT_MAGMOMS["MAGMOM"]
×
52

53

54
@unique
1✔
55
class Ordering(Enum):
1✔
56
    """
57
    Enumeration defining possible magnetic orderings.
58
    """
59

60
    FM = "FM"  # Ferromagnetic
1✔
61
    AFM = "AFM"  # Antiferromagnetic
1✔
62
    FiM = "FiM"  # Ferrimagnetic
1✔
63
    NM = "NM"  # Non-magnetic
1✔
64
    Unknown = "Unknown"
1✔
65

66

67
@unique
1✔
68
class OverwriteMagmomMode(Enum):
1✔
69
    """
70
    Enumeration defining different modes for analyzer.
71
    """
72

73
    none = "none"
1✔
74
    respect_sign = "respect_sign"
1✔
75
    respect_zero = "respect_zeros"
1✔
76
    replace_all = "replace_all"
1✔
77
    normalize = "normalize"
1✔
78

79

80
class CollinearMagneticStructureAnalyzer:
1✔
81
    """
82
    A class which provides a few helpful methods to analyze
83
    collinear magnetic structures.
84
    """
85

86
    def __init__(
1✔
87
        self,
88
        structure: Structure,
89
        overwrite_magmom_mode: OverwriteMagmomMode | str = "none",
90
        round_magmoms: bool = False,
91
        detect_valences: bool = False,
92
        make_primitive: bool = True,
93
        default_magmoms: dict | None = None,
94
        set_net_positive: bool = True,
95
        threshold: float = 0.00,
96
        threshold_nonmag: float = 0.1,
97
    ):
98
        """
99
        If magnetic moments are not defined, moments will be
100
        taken either from default_magmoms.yaml (similar to the
101
        default magmoms in MPRelaxSet, with a few extra definitions)
102
        or from a specie:magmom dict provided by the default_magmoms
103
        kwarg.
104

105
        Input magmoms can be replaced using the 'overwrite_magmom_mode'
106
        kwarg. This can be:
107
        * "none" to do nothing,
108
        * "respect_sign" which will overwrite existing magmoms with
109
          those from default_magmoms but will keep sites with positive magmoms
110
          positive, negative magmoms negative and zero magmoms zero,
111
        * "respect_zeros", which will give a ferromagnetic structure
112
          (all positive magmoms from default_magmoms) but still keep sites with
113
          zero magmoms as zero,
114
        * "replace_all" which will try to guess initial magmoms for
115
          all sites in the structure irrespective of input structure
116
          (this is most suitable for an initial DFT calculation),
117
        * "replace_all_if_undefined" is the same as "replace_all" but only if
118
          no magmoms are defined in input structure, otherwise it will respect
119
          existing magmoms.
120
        * "normalize" will normalize magmoms to unity, but will respect sign
121
          (used for comparing orderings), magmoms < theshhold will be set to zero
122

123
        Args:
124
            structure: input Structure object
125
            overwrite_magmom_mode: "respect_sign", "respect_zeros", "replace_all",
126
                "replace_all_if_undefined", "normalize" (default "none")
127
            round_magmoms: will round input magmoms to
128
                specified number of decimal places if integer is supplied, if set
129
                to a float will try and group magmoms together using a kernel density
130
                estimator of provided width, and extracting peaks of the estimator
131
                detect_valences: if True, will attempt to assign valences
132
                to input structure
133
            make_primitive: if True, will transform to primitive
134
                magnetic cell
135
            default_magmoms: (optional) dict specifying default magmoms
136
            set_net_positive: if True, will change sign of magnetic
137
                moments such that the net magnetization is positive. Argument will be
138
                ignored if mode "respect_sign" is used.
139
            threshold: number (in Bohr magnetons) below which magmoms
140
                will be rounded to zero
141
            threshold_nonmag: number (in Bohr magneton)
142
                below which nonmagnetic ions (with no magmom specified
143
                in default_magmoms) will be rounded to zero
144
        """
145
        if default_magmoms:
1✔
146
            self.default_magmoms = default_magmoms
×
147
        else:
148
            self.default_magmoms = DEFAULT_MAGMOMS
1✔
149

150
        structure = structure.copy()
1✔
151

152
        # check for disorder
153
        if not structure.is_ordered:
1✔
154
            raise NotImplementedError("Not implemented for disordered structures, make ordered approximation first.")
1✔
155

156
        if detect_valences:
1✔
157
            trans = AutoOxiStateDecorationTransformation()
×
158
            try:
×
159
                structure = trans.apply_transformation(structure)
×
160
            except ValueError:
×
161
                warnings.warn(f"Could not assign valences for {structure.composition.reduced_formula}")
×
162

163
        # check to see if structure has magnetic moments
164
        # on site properties or species spin properties,
165
        # prioritize site properties
166

167
        has_magmoms = bool(structure.site_properties.get("magmom", False))
1✔
168

169
        has_spin = False
1✔
170
        for comp in structure.species_and_occu:
1✔
171
            for sp in comp:
1✔
172
                if getattr(sp, "spin", False):
1✔
173
                    has_spin = True
×
174

175
        # perform input sanitation ...
176
        # rest of class will assume magnetic moments
177
        # are stored on site properties:
178
        # this is somewhat arbitrary, arguments can
179
        # be made for both approaches
180

181
        if has_magmoms and has_spin:
1✔
182
            raise ValueError(
×
183
                "Structure contains magnetic moments on both "
184
                "magmom site properties and spin species "
185
                "properties. This is ambiguous. Remove one or "
186
                "the other."
187
            )
188
        if has_magmoms:
1✔
189
            if None in structure.site_properties["magmom"]:
1✔
190
                warnings.warn(
×
191
                    "Be careful with mixing types in your magmom "
192
                    "site properties. Any 'None' magmoms have been "
193
                    "replaced with zero."
194
                )
195
            magmoms = [m or 0 for m in structure.site_properties["magmom"]]
1✔
196
        elif has_spin:
1✔
197
            magmoms = [getattr(sp, "spin", 0) for sp in structure.species]
×
198
            structure.remove_spin()
×
199
        else:
200
            # no magmoms present, add zero magmoms for now
201
            magmoms = [0] * len(structure)
1✔
202
            # and overwrite magmoms with default magmoms later unless otherwise stated
203
            if overwrite_magmom_mode == "replace_all_if_undefined":
1✔
204
                overwrite_magmom_mode = "replace_all"
1✔
205

206
        # test to see if input structure has collinear magmoms
207
        self.is_collinear = Magmom.are_collinear(magmoms)
1✔
208

209
        if not self.is_collinear:
1✔
210
            warnings.warn(
1✔
211
                "This class is not designed to be used with "
212
                "non-collinear structures. If your structure is "
213
                "only slightly non-collinear (e.g. canted) may still "
214
                "give useful results, but use with caution."
215
            )
216

217
        # this is for collinear structures only, make sure magmoms
218
        # are all floats
219
        magmoms = list(map(float, magmoms))
1✔
220

221
        # set properties that should be done /before/ we process input magmoms
222
        self.total_magmoms = sum(magmoms)
1✔
223
        self.magnetization = sum(magmoms) / structure.volume
1✔
224

225
        # round magmoms on magnetic ions below threshold to zero
226
        # and on non magnetic ions below threshold_nonmag
227
        magmoms = [
1✔
228
            m
229
            if abs(m) > threshold and a.species_string in self.default_magmoms
230
            else m
231
            if abs(m) > threshold_nonmag and a.species_string not in self.default_magmoms
232
            else 0
233
            for (m, a) in zip(magmoms, structure.sites)
234
        ]
235

236
        # overwrite existing magmoms with default_magmoms
237
        if overwrite_magmom_mode not in (
1✔
238
            "none",
239
            "respect_sign",
240
            "respect_zeros",
241
            "replace_all",
242
            "replace_all_if_undefined",
243
            "normalize",
244
        ):
245
            raise ValueError("Unsupported mode.")
×
246

247
        for idx, site in enumerate(structure):
1✔
248
            if site.species_string in self.default_magmoms:
1✔
249
                # look for species first, e.g. Fe2+
250
                default_magmom = self.default_magmoms[site.species_string]
1✔
251
            elif isinstance(site.specie, Species) and str(site.specie.element) in self.default_magmoms:
1✔
252
                # look for element, e.g. Fe
253
                default_magmom = self.default_magmoms[str(site.specie.element)]
1✔
254
            else:
255
                default_magmom = 0
1✔
256

257
            # overwrite_magmom_mode = "respect_sign" will change magnitude of
258
            # existing moments only, and keep zero magmoms as
259
            # zero: it will keep the magnetic ordering intact
260

261
            if overwrite_magmom_mode == "respect_sign":
1✔
262
                set_net_positive = False
1✔
263
                if magmoms[idx] > 0:
1✔
264
                    magmoms[idx] = default_magmom
1✔
265
                elif magmoms[idx] < 0:
1✔
266
                    magmoms[idx] = -default_magmom
1✔
267

268
            # overwrite_magmom_mode = "respect_zeros" will give a ferromagnetic
269
            # structure but will keep zero magmoms as zero
270

271
            elif overwrite_magmom_mode == "respect_zeros":
1✔
272
                if magmoms[idx] != 0:
1✔
273
                    magmoms[idx] = default_magmom
1✔
274

275
            # overwrite_magmom_mode = "replace_all" will ignore input magmoms
276
            # and give a ferromagnetic structure with magnetic
277
            # moments on *all* atoms it thinks could be magnetic
278

279
            elif overwrite_magmom_mode == "replace_all":
1✔
280
                magmoms[idx] = default_magmom
1✔
281

282
            # overwrite_magmom_mode = "normalize" set magmoms magnitude to 1
283

284
            elif overwrite_magmom_mode == "normalize":
1✔
285
                if magmoms[idx] != 0:
1✔
286
                    magmoms[idx] = int(magmoms[idx] / abs(magmoms[idx]))
1✔
287

288
        # round magmoms, used to smooth out computational data
289
        magmoms = self._round_magmoms(magmoms, round_magmoms) if round_magmoms else magmoms  # type: ignore
1✔
290

291
        if set_net_positive:
1✔
292
            sign = np.sum(magmoms)
1✔
293
            if sign < 0:
1✔
294
                magmoms = [-x for x in magmoms]
1✔
295

296
        structure.add_site_property("magmom", magmoms)
1✔
297

298
        if make_primitive:
1✔
299
            structure = structure.get_primitive_structure(use_site_props=True)
1✔
300

301
        self.structure = structure
1✔
302

303
    @staticmethod
1✔
304
    def _round_magmoms(magmoms: VectorLike, round_magmoms_mode: int | float) -> np.ndarray:
1✔
305
        """If round_magmoms_mode is an integer, simply round to that number
306
        of decimal places, else if set to a float will try and round
307
        intelligently by grouping magmoms.
308
        """
309
        if isinstance(round_magmoms_mode, int):
1✔
310
            # simple rounding to number of decimal places
311
            magmoms = np.around(magmoms, decimals=round_magmoms_mode)
×
312

313
        elif isinstance(round_magmoms_mode, float):
1✔
314
            try:
1✔
315
                # get range of possible magmoms, pad by 50% just to be safe
316
                range_m = max([max(magmoms), abs(min(magmoms))]) * 1.5
1✔
317

318
                # construct kde, here "round_magmoms_mode" is the width of the kde
319
                kernel = gaussian_kde(magmoms, bw_method=round_magmoms_mode)
1✔
320

321
                # with a linearly spaced grid 1000x finer than width
322
                xgrid = np.linspace(-range_m, range_m, int(1000 * range_m / round_magmoms_mode))
1✔
323

324
                # and evaluate the kde on this grid, extracting the maxima of the kde peaks
325
                kernel_m = kernel.evaluate(xgrid)
1✔
326
                extrema = xgrid[argrelextrema(kernel_m, comparator=np.greater)]
1✔
327

328
                # round magmoms to these extrema
329
                magmoms = [extrema[(np.abs(extrema - m)).argmin()] for m in magmoms]
1✔
330

331
            except Exception as e:
×
332
                # TODO: typically a singular matrix warning, investigate this
333
                warnings.warn("Failed to round magmoms intelligently, falling back to simple rounding.")
×
334
                warnings.warn(str(e))
×
335

336
            # and finally round roughly to the number of significant figures in our kde width
337
            num_decimals = len(str(round_magmoms_mode).split(".")[1]) + 1
1✔
338
            magmoms = np.around(magmoms, decimals=num_decimals)
1✔
339

340
        return np.array(magmoms)
1✔
341

342
    def get_structure_with_spin(self) -> Structure:
1✔
343
        """Returns a Structure with species decorated with spin values instead
344
        of using magmom site properties.
345
        """
346
        structure = self.structure.copy()
1✔
347
        structure.add_spin_by_site(structure.site_properties["magmom"])
1✔
348
        structure.remove_site_property("magmom")
1✔
349

350
        return structure
1✔
351

352
    def get_structure_with_only_magnetic_atoms(self, make_primitive: bool = True) -> Structure:
1✔
353
        """Returns a Structure with only magnetic atoms present.
354

355
        Args:
356
          make_primitive: Whether to make structure primitive after
357
            removing non-magnetic atoms (Default value = True)
358

359
        Returns: Structure
360
        """
361
        sites = [site for site in self.structure if abs(site.properties["magmom"]) > 0]
1✔
362

363
        structure = Structure.from_sites(sites)
1✔
364

365
        if make_primitive:
1✔
366
            structure = structure.get_primitive_structure(use_site_props=True)
1✔
367

368
        return structure
1✔
369

370
    def get_nonmagnetic_structure(self, make_primitive: bool = True) -> Structure:
1✔
371
        """Returns a Structure without magnetic moments defined.
372

373
        Args:
374
          make_primitive: Whether to make structure primitive after
375
            removing magnetic information (Default value = True)
376

377
        Returns:
378
          Structure
379
        """
380
        structure = self.structure.copy()
1✔
381
        structure.remove_site_property("magmom")
1✔
382

383
        if make_primitive:
1✔
384
            structure = structure.get_primitive_structure()
1✔
385

386
        return structure
1✔
387

388
    def get_ferromagnetic_structure(self, make_primitive: bool = True) -> Structure:
1✔
389
        """Returns a Structure with all magnetic moments positive
390
        or zero.
391

392
        Args:
393
          make_primitive: Whether to make structure primitive after
394
            making all magnetic moments positive (Default value = True)
395

396
        Returns:
397
          Structure
398
        """
399
        structure = self.structure.copy()
1✔
400

401
        structure.add_site_property("magmom", [abs(m) for m in self.magmoms])
1✔
402

403
        if make_primitive:
1✔
404
            structure = structure.get_primitive_structure(use_site_props=True)
1✔
405

406
        return structure
1✔
407

408
    @property
1✔
409
    def is_magnetic(self) -> bool:
1✔
410
        """Convenience property, returns True if any non-zero magmoms present."""
411
        return any(map(abs, self.structure.site_properties["magmom"]))
1✔
412

413
    @property
1✔
414
    def magmoms(self) -> np.ndarray:
1✔
415
        """Convenience property, returns magmoms as a numpy array."""
416

417
        return np.array(self.structure.site_properties["magmom"])
1✔
418

419
    @property
1✔
420
    def types_of_magnetic_species(
1✔
421
        self,
422
    ) -> tuple[Element | Species | DummySpecies, ...]:
423
        """Equivalent to Structure.types_of_specie but only returns
424
        magnetic species.
425

426
        Returns: types of Species as a list
427
        """
428
        if self.number_of_magnetic_sites > 0:
1✔
429
            structure = self.get_structure_with_only_magnetic_atoms()
1✔
430
            return tuple(sorted(structure.types_of_species))
1✔
431
        return tuple()
×
432

433
    @property
1✔
434
    def types_of_magnetic_specie(
1✔
435
        self,
436
    ) -> tuple[Element | Species | DummySpecies, ...]:
437
        """
438
        Specie->Species rename. Used to maintain backwards compatibility.
439
        """
440
        return self.types_of_magnetic_species
×
441

442
    @property
1✔
443
    def magnetic_species_and_magmoms(self) -> dict[str, Any]:
1✔
444
        """Returns a dict of magnetic species and the magnitude of
445
        their associated magmoms. Will return a list if there are
446
        multiple magmoms per species.
447

448
        Returns: dict of magnetic species and magmoms
449
        """
450
        structure = self.get_ferromagnetic_structure()
1✔
451

452
        magtypes: dict = {str(site.specie): set() for site in structure if site.properties["magmom"] != 0}
1✔
453

454
        for site in structure:
1✔
455
            if site.properties["magmom"] != 0:
1✔
456
                magtypes[str(site.specie)].add(site.properties["magmom"])
1✔
457

458
        for sp, magmoms in magtypes.items():
1✔
459
            if len(magmoms) == 1:
1✔
460
                magtypes[sp] = magmoms.pop()
1✔
461
            else:
462
                magtypes[sp] = sorted(list(magmoms))
1✔
463

464
        return magtypes
1✔
465

466
    @property
1✔
467
    def number_of_magnetic_sites(self) -> int:
1✔
468
        """Number of magnetic sites present in structure."""
469
        return int(np.sum([abs(m) > 0 for m in self.magmoms]))
1✔
470

471
    def number_of_unique_magnetic_sites(self, symprec: float = 1e-3, angle_tolerance: float = 5) -> int:
1✔
472
        """
473
        Args:
474
          symprec: same as in SpacegroupAnalyzer (Default value = 1e-3)
475
          angle_tolerance: same as in SpacegroupAnalyzer (Default value = 5)
476

477
        Returns: Number of symmetrically-distinct magnetic sites present
478
        in structure.
479
        """
480
        structure = self.get_nonmagnetic_structure()
1✔
481

482
        sga = SpacegroupAnalyzer(structure, symprec=symprec, angle_tolerance=angle_tolerance)
1✔
483

484
        symm_structure = sga.get_symmetrized_structure()
1✔
485

486
        num_unique_mag_sites = 0
1✔
487

488
        for group_of_sites in symm_structure.equivalent_sites:
1✔
489
            if group_of_sites[0].specie in self.types_of_magnetic_species:
1✔
490
                num_unique_mag_sites += 1
1✔
491

492
        return num_unique_mag_sites
1✔
493

494
    @property
1✔
495
    def ordering(self) -> Ordering:
1✔
496
        """Applies heuristics to return a magnetic ordering for a collinear
497
        magnetic structure. Result is not guaranteed for correctness.
498

499
        Returns: Ordering Enum ('FiM' is used as the abbreviation for
500
        ferrimagnetic)
501
        """
502
        if not self.is_collinear:
1✔
503
            warnings.warn("Detecting ordering in non-collinear structures not yet implemented.")
×
504
            return Ordering.Unknown
×
505

506
        if "magmom" not in self.structure.site_properties:
1✔
507
            # maybe this was a non-spin-polarized calculation, or we've
508
            # lost the magnetic moment information
509
            return Ordering.Unknown
×
510

511
        magmoms = self.magmoms
1✔
512

513
        max_magmom = max(magmoms)
1✔
514

515
        total_magnetization = abs(sum(magmoms))
1✔
516

517
        is_potentially_ferromagnetic = np.all(magmoms >= 0) or np.all(magmoms <= 0)
1✔
518

519
        if total_magnetization > 0 and is_potentially_ferromagnetic:
1✔
520
            return Ordering.FM
1✔
521
        if total_magnetization > 0:
1✔
522
            return Ordering.FiM
1✔
523
        if max_magmom > 0:
1✔
524
            return Ordering.AFM
×
525
        return Ordering.NM
1✔
526

527
    def get_exchange_group_info(self, symprec: float = 1e-2, angle_tolerance: float = 5.0) -> tuple[str, int]:
1✔
528
        """Returns the information on the symmetry of the Hamiltonian
529
        describing the exchange energy of the system, taking into
530
        account relative direction of magnetic moments but not their
531
        absolute direction.
532

533
        This is not strictly accurate (e.g. some/many atoms will
534
        have zero magnetic moments), but defining symmetry this
535
        way is a useful way of keeping track of distinct magnetic
536
        orderings within pymatgen.
537

538
        Args:
539
          symprec: same as SpacegroupAnalyzer (Default value = 1e-2)
540
          angle_tolerance: same as SpacegroupAnalyzer (Default value = 5.0)
541

542
        Returns:
543
          spacegroup_symbol, international_number
544
        """
545
        structure = self.get_structure_with_spin()
1✔
546

547
        return structure.get_space_group_info(symprec=symprec, angle_tolerance=angle_tolerance)
1✔
548

549
    def matches_ordering(self, other: Structure) -> bool:
1✔
550
        """Compares the magnetic orderings of one structure with another.
551

552
        Args:
553
          other: Structure to compare
554

555
        Returns: True or False
556
        """
557
        a = CollinearMagneticStructureAnalyzer(
1✔
558
            self.structure, overwrite_magmom_mode="normalize"
559
        ).get_structure_with_spin()
560

561
        # sign of spins doesn't matter, so we're comparing both
562
        # positive and negative versions of the structure
563
        # this code is possibly redundant, but is included out of
564
        # an abundance of caution
565
        b_positive = CollinearMagneticStructureAnalyzer(other, overwrite_magmom_mode="normalize", make_primitive=False)
1✔
566

567
        b_negative = b_positive.structure.copy()
1✔
568
        b_negative.add_site_property("magmom", np.multiply(-1, b_negative.site_properties["magmom"]))
1✔
569

570
        b_negative = CollinearMagneticStructureAnalyzer(
1✔
571
            b_negative, overwrite_magmom_mode="normalize", make_primitive=False
572
        )
573

574
        b_positive = b_positive.get_structure_with_spin()
1✔
575
        b_negative = b_negative.get_structure_with_spin()
1✔
576

577
        return a.matches(b_positive) or a.matches(b_negative)
1✔
578

579
    def __str__(self):
1✔
580
        """
581
        Sorts a Structure (by fractional coordinate), and
582
        prints sites with magnetic information. This is
583
        useful over Structure.__str__ because sites are in
584
        a consistent order, which makes visual comparison between
585
        two identical Structures with different magnetic orderings
586
        easier.
587
        """
588
        frac_coords = self.structure.frac_coords
1✔
589
        sorted_indices = np.lexsort((frac_coords[:, 2], frac_coords[:, 1], frac_coords[:, 0]))
1✔
590
        s = Structure.from_sites([self.structure[idx] for idx in sorted_indices])
1✔
591

592
        # adapted from Structure.__repr__
593
        outs = ["Structure Summary", repr(s.lattice)]
1✔
594
        outs.append("Magmoms Sites")
1✔
595
        for site in s:
1✔
596
            if site.properties["magmom"] != 0:
1✔
597
                prefix = f"{site.properties['magmom']:+.2f}   "
1✔
598
            else:
599
                prefix = "        "
1✔
600
            outs.append(prefix + repr(site))
1✔
601
        return "\n".join(outs)
1✔
602

603

604
class MagneticStructureEnumerator:
1✔
605
    """Combines MagneticStructureAnalyzer and MagOrderingTransformation to
606
    automatically generate a set of transformations for a given structure
607
    and produce a list of plausible magnetic orderings.
608
    """
609

610
    available_strategies = (
1✔
611
        "ferromagnetic",
612
        "antiferromagnetic",
613
        "ferrimagnetic_by_motif",
614
        "ferrimagnetic_by_species",
615
        "antiferromagnetic_by_motif",
616
        "nonmagnetic",
617
    )
618

619
    def __init__(
1✔
620
        self,
621
        structure: Structure,
622
        default_magmoms: dict[str, float] | None = None,
623
        strategies: list[str]
624
        | tuple[str, ...] = (
625
            "ferromagnetic",
626
            "antiferromagnetic",
627
        ),
628
        automatic: bool = True,
629
        truncate_by_symmetry: bool = True,
630
        transformation_kwargs: dict | None = None,
631
    ):
632
        """
633
        This class will try generated different collinear
634
        magnetic orderings for a given input structure.
635

636
        If the input structure has magnetic moments defined, it
637
        is possible to use these as a hint as to which elements are
638
        magnetic, otherwise magnetic elements will be guessed
639
        (this can be changed using default_magmoms kwarg).
640

641
        Args:
642
            structure: input structure
643
            default_magmoms: (optional, defaults provided) dict of
644
                magnetic elements to their initial magnetic moments in µB, generally
645
                these are chosen to be high-spin since they can relax to a low-spin
646
                configuration during a DFT electronic configuration
647
            strategies: different ordering strategies to use, choose from:
648
                ferromagnetic, antiferromagnetic, antiferromagnetic_by_motif,
649
                ferrimagnetic_by_motif and ferrimagnetic_by_species (here, "motif",
650
                means to use a different ordering parameter for symmetry inequivalent
651
                sites)
652
            automatic: if True, will automatically choose sensible strategies
653
            truncate_by_symmetry: if True, will remove very unsymmetrical
654
                orderings that are likely physically implausible
655
            transformation_kwargs: keyword arguments to pass to
656
                MagOrderingTransformation, to change automatic cell size limits, etc.
657
        """
658
        self.logger = logging.getLogger(type(self).__name__)
×
659

660
        self.structure = structure
×
661

662
        # decides how to process input structure, which sites are magnetic
663
        self.default_magmoms = default_magmoms
×
664

665
        # different strategies to attempt, default is usually reasonable
666
        self.strategies = list(strategies)
×
667
        # and whether to automatically add strategies that may be appropriate
668
        self.automatic = automatic
×
669

670
        # and whether to discard low symmetry structures
671
        self.truncate_by_symmetry = truncate_by_symmetry
×
672

673
        # other settings
674
        self.num_orderings = 64
×
675
        self.max_unique_sites = 8
×
676

677
        # kwargs to pass to transformation (ultimately to enumlib)
678
        default_transformation_kwargs = {"check_ordered_symmetry": False, "timeout": 5}
×
679
        transformation_kwargs = transformation_kwargs or {}
×
680
        transformation_kwargs.update(default_transformation_kwargs)
×
681
        self.transformation_kwargs = transformation_kwargs
×
682

683
        # our magnetically ordered structures will be
684
        # stored here once generated and also store which
685
        # transformation created them, this is used for
686
        # book-keeping/user interest, and
687
        # is be a list of strings in ("fm", "afm",
688
        # "ferrimagnetic_by_species", "ferrimagnetic_by_motif",
689
        # "afm_by_motif", "input_structure")
690
        self.ordered_structures: list[Structure] = []
×
691
        self.ordered_structure_origins: list[str] = []
×
692

693
        formula = structure.composition.reduced_formula
×
694

695
        # to process disordered magnetic structures, first make an
696
        # ordered approximation
697
        if not structure.is_ordered:
×
698
            raise ValueError(f"Please obtain an ordered approximation of the input structure ({formula}).")
×
699

700
        # CollinearMagneticStructureAnalyzer is used throughout:
701
        # it can tell us whether the input is itself collinear (if not,
702
        # this workflow is not appropriate), and has many convenience
703
        # methods e.g. magnetic structure matching, etc.
704
        self.input_analyzer = CollinearMagneticStructureAnalyzer(
×
705
            structure, default_magmoms=default_magmoms, overwrite_magmom_mode="none"
706
        )
707

708
        # this workflow enumerates structures with different combinations
709
        # of up and down spin and does not include spin-orbit coupling:
710
        # if your input structure has vector magnetic moments, this
711
        # workflow is not appropriate
712
        if not self.input_analyzer.is_collinear:
×
713
            raise ValueError(f"Input structure ({formula}) is non-collinear.")
×
714

715
        self.sanitized_structure = self._sanitize_input_structure(structure)
×
716

717
        # we will first create a set of transformations
718
        # and then apply them to our input structure
719
        self.transformations = self._generate_transformations(self.sanitized_structure)
×
720
        self._generate_ordered_structures(self.sanitized_structure, self.transformations)
×
721

722
    @staticmethod
1✔
723
    def _sanitize_input_structure(input_structure: Structure) -> Structure:
1✔
724
        """Sanitize our input structure by removing magnetic information
725
        and making primitive.
726

727
        Args:
728
          input_structure: Structure
729

730
        Returns: Structure
731
        """
732
        input_structure = input_structure.copy()
×
733

734
        # remove any annotated spin
735
        input_structure.remove_spin()
×
736

737
        # sanitize input structure: first make primitive ...
738
        input_structure = input_structure.get_primitive_structure(use_site_props=False)
×
739

740
        # ... and strip out existing magmoms, which can cause conflicts
741
        # with later transformations otherwise since sites would end up
742
        # with both magmom site properties and Species spins defined
743
        if "magmom" in input_structure.site_properties:
×
744
            input_structure.remove_site_property("magmom")
×
745

746
        return input_structure
×
747

748
    def _generate_transformations(self, structure: Structure) -> dict[str, MagOrderingTransformation]:
1✔
749
        """The central problem with trying to enumerate magnetic orderings is
750
        that we have to enumerate orderings that might plausibly be magnetic
751
        ground states, while not enumerating orderings that are physically
752
        implausible. The problem is that it is not always obvious by e.g.
753
        symmetry arguments alone which orderings to prefer. Here, we use a
754
        variety of strategies (heuristics) to enumerate plausible orderings,
755
        and later discard any duplicates that might be found by multiple
756
        strategies. This approach is not ideal, but has been found to be
757
        relatively robust over a wide range of magnetic structures.
758

759
        Args:
760
          structure: A sanitized input structure (_sanitize_input_structure)
761
        Returns: A dict of a transformation class instance (values) and name of
762
        enumeration strategy (keys)
763

764
        Returns: dict of Transformations keyed by strategy
765
        """
766
        formula = structure.composition.reduced_formula
×
767
        transformations: dict[str, MagOrderingTransformation] = {}
×
768

769
        # analyzer is used to obtain information on sanitized input
770
        analyzer = CollinearMagneticStructureAnalyzer(
×
771
            structure,
772
            default_magmoms=self.default_magmoms,
773
            overwrite_magmom_mode="replace_all",
774
        )
775

776
        if not analyzer.is_magnetic:
×
777
            raise ValueError(
×
778
                "Not detected as magnetic, add a new default magmom for the element you believe may be magnetic?"
779
            )
780

781
        # now we can begin to generate our magnetic orderings
782
        self.logger.info(f"Generating magnetic orderings for {formula}")
×
783

784
        mag_species_spin = analyzer.magnetic_species_and_magmoms
×
785
        types_mag_species = sorted(
×
786
            analyzer.types_of_magnetic_species,
787
            key=lambda sp: analyzer.default_magmoms.get(str(sp), 0),
788
            reverse=True,
789
        )
790
        num_mag_sites = analyzer.number_of_magnetic_sites
×
791
        num_unique_sites = analyzer.number_of_unique_magnetic_sites()
×
792

793
        # enumerations become too slow as number of unique sites (and thus
794
        # permutations) increase, 8 is a soft limit, this can be increased
795
        # but do so with care
796
        if num_unique_sites > self.max_unique_sites:
×
797
            raise ValueError("Too many magnetic sites to sensibly perform enumeration.")
×
798

799
        # maximum cell size to consider: as a rule of thumb, if the primitive cell
800
        # contains a large number of magnetic sites, perhaps we only need to enumerate
801
        # within one cell, whereas on the other extreme if the primitive cell only
802
        # contains a single magnetic site, we have to create larger supercells
803
        if "max_cell_size" not in self.transformation_kwargs:
×
804
            # TODO: change to 8 / num_mag_sites ?
805
            self.transformation_kwargs["max_cell_size"] = max(1, int(4 / num_mag_sites))
×
806
        self.logger.info(f"Max cell size set to {self.transformation_kwargs['max_cell_size']}")
×
807

808
        # when enumerating ferrimagnetic structures, it's useful to detect
809
        # symmetrically distinct magnetic sites, since different
810
        # local environments can result in different magnetic order
811
        # (e.g. inverse spinels)
812
        # initially, this was done by co-ordination number, but is
813
        # now done by a full symmetry analysis
814
        sga = SpacegroupAnalyzer(structure)
×
815
        structure_sym = sga.get_symmetrized_structure()
×
816
        wyckoff = ["n/a"] * len(structure)
×
817
        for indices, symbol in zip(structure_sym.equivalent_indices, structure_sym.wyckoff_symbols):
×
818
            for index in indices:
×
819
                wyckoff[index] = symbol
×
820
        is_magnetic_sites = [site.specie in types_mag_species for site in structure]
×
821
        # we're not interested in sites that we don't think are magnetic,
822
        # set these symbols to None to filter them out later
823
        wyckoff = [
×
824
            symbol if is_magnetic_site else "n/a" for symbol, is_magnetic_site in zip(wyckoff, is_magnetic_sites)
825
        ]
826
        structure.add_site_property("wyckoff", wyckoff)
×
827
        wyckoff_symbols = set(wyckoff) - {"n/a"}
×
828

829
        # if user doesn't specifically request ferrimagnetic orderings,
830
        # we apply a heuristic as to whether to attempt them or not
831
        if self.automatic:
×
832
            if (
×
833
                "ferrimagnetic_by_motif" not in self.strategies
834
                and len(wyckoff_symbols) > 1
835
                and len(types_mag_species) == 1
836
            ):
837
                self.strategies += ["ferrimagnetic_by_motif"]
×
838

839
            if (
×
840
                "antiferromagnetic_by_motif" not in self.strategies
841
                and len(wyckoff_symbols) > 1
842
                and len(types_mag_species) == 1
843
            ):
844
                self.strategies += ["antiferromagnetic_by_motif"]
×
845

846
            if "ferrimagnetic_by_species" not in self.strategies and len(types_mag_species) > 1:
×
847
                self.strategies += ["ferrimagnetic_by_species"]
×
848

849
        # we start with a ferromagnetic ordering
850
        if "ferromagnetic" in self.strategies:
×
851
            # TODO: remove 0 spins !
852

853
            fm_structure = analyzer.get_ferromagnetic_structure()
×
854
            # store magmom as spin property, to be consistent with output from
855
            # other transformations
856
            fm_structure.add_spin_by_site(fm_structure.site_properties["magmom"])  # type: ignore[arg-type]
×
857
            fm_structure.remove_site_property("magmom")
×
858

859
            # we now have our first magnetic ordering...
860
            self.ordered_structures.append(fm_structure)
×
861
            self.ordered_structure_origins.append("fm")
×
862

863
        # we store constraint(s) for each strategy first,
864
        # and then use each to perform a transformation later
865
        all_constraints: dict[str, Any] = {}
×
866

867
        # ...to which we can add simple AFM cases first...
868
        if "antiferromagnetic" in self.strategies:
×
869
            constraint = MagOrderParameterConstraint(
×
870
                0.5,
871
                # TODO: update MagOrderParameterConstraint in
872
                # pymatgen to take types_mag_species directly
873
                species_constraints=list(map(str, types_mag_species)),
874
            )
875
            all_constraints["afm"] = [constraint]
×
876

877
            # allows for non-magnetic sublattices
878
            if len(types_mag_species) > 1:
×
879
                for sp in types_mag_species:
×
880
                    constraints = [MagOrderParameterConstraint(0.5, species_constraints=str(sp))]
×
881

882
                    all_constraints[f"afm_by_{sp}"] = constraints
×
883

884
        # ...and then we also try ferrimagnetic orderings by motif if a
885
        # single magnetic species is present...
886
        if "ferrimagnetic_by_motif" in self.strategies and len(wyckoff_symbols) > 1:
×
887
            # these orderings are AFM on one local environment, and FM on the rest
888
            for symbol in wyckoff_symbols:
×
889
                constraints = [
×
890
                    MagOrderParameterConstraint(0.5, site_constraint_name="wyckoff", site_constraints=symbol),
891
                    MagOrderParameterConstraint(
892
                        1.0,
893
                        site_constraint_name="wyckoff",
894
                        site_constraints=list(wyckoff_symbols - {symbol}),
895
                    ),
896
                ]
897

898
                all_constraints[f"ferri_by_motif_{symbol}"] = constraints
×
899

900
        # and also try ferrimagnetic when there are multiple magnetic species
901
        if "ferrimagnetic_by_species" in self.strategies:
×
902
            sp_list = [str(site.specie) for site in structure]
×
903
            num_sp = {sp: sp_list.count(str(sp)) for sp in types_mag_species}
×
904
            total_mag_sites = sum(num_sp.values())
×
905

906
            for sp in types_mag_species:
×
907
                # attempt via a global order parameter
908
                all_constraints[f"ferri_by_{sp}"] = num_sp[sp] / total_mag_sites
×
909

910
                # attempt via afm on sp, fm on remaining species
911

912
                constraints = [
×
913
                    MagOrderParameterConstraint(0.5, species_constraints=str(sp)),
914
                    MagOrderParameterConstraint(
915
                        1.0,
916
                        species_constraints=list(map(str, set(types_mag_species) - {sp})),
917
                    ),
918
                ]
919

920
                all_constraints[f"ferri_by_{sp}_afm"] = constraints
×
921

922
        # ...and finally, we can try orderings that are AFM on one local
923
        # environment, and non-magnetic on the rest -- this is less common
924
        # but unless explicitly attempted, these states are unlikely to be found
925
        if "antiferromagnetic_by_motif" in self.strategies:
×
926
            for symbol in wyckoff_symbols:
×
927
                constraints = [
×
928
                    MagOrderParameterConstraint(0.5, site_constraint_name="wyckoff", site_constraints=symbol)
929
                ]
930

931
                all_constraints[f"afm_by_motif_{symbol}"] = constraints
×
932

933
        # and now construct all our transformations for each strategy
934
        transformations = {}
×
935
        for name, constraints in all_constraints.items():
×
936
            trans = MagOrderingTransformation(
×
937
                mag_species_spin, order_parameter=constraints, **self.transformation_kwargs
938
            )
939

940
            transformations[name] = trans
×
941

942
        return transformations
×
943

944
    def _generate_ordered_structures(
1✔
945
        self,
946
        sanitized_input_structure: Structure,
947
        transformations: dict[str, MagOrderingTransformation],
948
    ):
949
        """Apply our input structure to our list of transformations and output a list
950
        of ordered structures that have been pruned for duplicates and for those
951
        with low symmetry (optional).
952

953
        Args:
954
            sanitized_input_structure: A sanitized input structure
955
            (_sanitize_input_structure)
956
            transformations: A dict of transformations (values) and name of
957
            enumeration strategy (key), the enumeration strategy name is just
958
            for record keeping
959

960
        Returns: None (sets self.ordered_structures
961
        and self.ordered_structures_origins instance variables)
962

963
        Returns: List of Structures
964
        """
965
        ordered_structures = self.ordered_structures
×
966
        ordered_structures_origins = self.ordered_structure_origins
×
967

968
        # utility function to combine outputs from several transformations
969
        def _add_structures(ordered_structures, ordered_structures_origins, structures_to_add, origin=""):
×
970
            """Transformations with return_ranked_list can return either
971
            just Structures or dicts (or sometimes lists!) -- until this
972
            is fixed, we use this function to concat structures given
973
            by the transformation.
974
            """
975
            if structures_to_add:
×
976
                if isinstance(structures_to_add, Structure):
×
977
                    structures_to_add = [structures_to_add]
×
978
                structures_to_add = [s["structure"] if isinstance(s, dict) else s for s in structures_to_add]
×
979
                # concatenation
980
                ordered_structures += structures_to_add
×
981
                ordered_structures_origins += [origin] * len(structures_to_add)
×
982
                self.logger.info(f"Adding {len(structures_to_add)} ordered structures: {origin}")
×
983

984
            return ordered_structures, ordered_structures_origins
×
985

986
        for origin, trans in self.transformations.items():
×
987
            structures_to_add = trans.apply_transformation(
×
988
                self.sanitized_structure, return_ranked_list=self.num_orderings
989
            )
990
            ordered_structures, ordered_structures_origins = _add_structures(
×
991
                ordered_structures,
992
                ordered_structures_origins,
993
                structures_to_add,
994
                origin=origin,
995
            )
996

997
        # in case we've introduced duplicates, let's remove them
998
        self.logger.info("Pruning duplicate structures.")
×
999
        structures_to_remove: list[int] = []
×
1000
        for idx, ordered_structure in enumerate(ordered_structures):
×
1001
            if idx not in structures_to_remove:
×
1002
                duplicate_checker = CollinearMagneticStructureAnalyzer(ordered_structure, overwrite_magmom_mode="none")
×
1003
                for check_idx, check_structure in enumerate(ordered_structures):
×
1004
                    if check_idx not in structures_to_remove and check_idx != idx:
×
1005
                        if duplicate_checker.matches_ordering(check_structure):
×
1006
                            structures_to_remove.append(check_idx)
×
1007

1008
        if len(structures_to_remove) == 0:
×
1009
            self.logger.info(f"Removing {len(structures_to_remove)} duplicate ordered structures")
×
1010
            ordered_structures = [s for idx, s in enumerate(ordered_structures) if idx not in structures_to_remove]
×
1011
            ordered_structures_origins = [
×
1012
                o for idx, o in enumerate(ordered_structures_origins) if idx not in structures_to_remove
1013
            ]
1014

1015
        # also remove low symmetry structures
1016
        if self.truncate_by_symmetry:
×
1017
            # by default, keep structures with 5 most symmetric space groups
1018
            if not isinstance(self.truncate_by_symmetry, int):
×
1019
                self.truncate_by_symmetry = 5
×
1020

1021
            self.logger.info("Pruning low symmetry structures.")
×
1022

1023
            # first get a list of symmetries present
1024
            symmetry_int_numbers = [s.get_space_group_info()[1] for s in ordered_structures]
×
1025

1026
            # then count the number of symmetry operations for that space group
1027
            num_sym_ops = [len(SpaceGroup.from_int_number(n).symmetry_ops) for n in symmetry_int_numbers]
×
1028

1029
            # find the largest values...
1030
            max_symmetries = sorted(list(set(num_sym_ops)), reverse=True)
×
1031

1032
            # ...and decide which ones to keep
1033
            if len(max_symmetries) > self.truncate_by_symmetry:
×
1034
                max_symmetries = max_symmetries[0:5]
×
1035
            structs_to_keep = [(idx, num) for idx, num in enumerate(num_sym_ops) if num in max_symmetries]
×
1036

1037
            # sort so that highest symmetry structs are first
1038
            structs_to_keep = sorted(structs_to_keep, key=lambda x: (x[1], -x[0]), reverse=True)
×
1039

1040
            self.logger.info(
×
1041
                f"Removing {len(ordered_structures) - len(structs_to_keep)} low symmetry ordered structures"
1042
            )
1043

1044
            ordered_structures = [ordered_structures[i] for i, _ in structs_to_keep]
×
1045
            ordered_structures_origins = [ordered_structures_origins[i] for i, _ in structs_to_keep]
×
1046

1047
            # and ensure fm is always at index 0
1048
            fm_index = ordered_structures_origins.index("fm")
×
1049
            ordered_structures.insert(0, ordered_structures.pop(fm_index))
×
1050
            ordered_structures_origins.insert(0, ordered_structures_origins.pop(fm_index))
×
1051

1052
        # if our input structure isn't in our generated structures,
1053
        # let's add it manually and also keep a note of which structure
1054
        # is our input: this is mostly for book-keeping/benchmarking
1055
        self.input_index = None
×
1056
        self.input_origin = None
×
1057
        if self.input_analyzer.ordering != Ordering.NM:
×
1058
            matches = [self.input_analyzer.matches_ordering(s) for s in ordered_structures]
×
1059
            if not any(matches):
×
1060
                ordered_structures.append(self.input_analyzer.structure)
×
1061
                ordered_structures_origins.append("input")
×
1062
                self.logger.info("Input structure not present in enumerated structures, adding...")
×
1063
            else:
1064
                self.logger.info(f"Input structure was found in enumerated structures at index {matches.index(True)}")
×
1065
                self.input_index = matches.index(True)
×
1066
                self.input_origin = ordered_structures_origins[self.input_index]
×
1067

1068
        self.ordered_structures = ordered_structures
×
1069
        self.ordered_structure_origins = ordered_structures_origins
×
1070

1071

1072
MagneticDeformation = namedtuple("MagneticDeformation", "type deformation")
1✔
1073

1074

1075
def magnetic_deformation(structure_A: Structure, structure_B: Structure) -> MagneticDeformation:
1✔
1076
    """Calculates 'magnetic deformation proxy',
1077
    a measure of deformation (norm of finite strain)
1078
    between 'non-magnetic' (non-spin-polarized) and
1079
    ferromagnetic structures.
1080

1081
    Adapted from Bocarsly et al. 2017,
1082
    doi: 10.1021/acs.chemmater.6b04729
1083

1084
    Args:
1085
      structure_A: Structure
1086
      structure_B: Structure
1087

1088
    Returns: Magnetic deformation
1089
    """
1090

1091
    # retrieve orderings of both input structures
1092
    ordering_a = CollinearMagneticStructureAnalyzer(structure_A, overwrite_magmom_mode="none").ordering
1✔
1093
    ordering_b = CollinearMagneticStructureAnalyzer(structure_B, overwrite_magmom_mode="none").ordering
1✔
1094

1095
    # get a type string, this is either 'NM-FM' for between non-magnetic
1096
    # and ferromagnetic, as in Bocarsly paper, or e.g. 'FM-AFM'
1097
    type_str = f"{ordering_a.value}-{ordering_b.value}"
1✔
1098

1099
    lattice_a = structure_A.lattice.matrix.T
1✔
1100
    lattice_b = structure_B.lattice.matrix.T
1✔
1101
    lattice_a_inv = np.linalg.inv(lattice_a)
1✔
1102
    p = np.dot(lattice_a_inv, lattice_b)
1✔
1103
    eta = 0.5 * (np.dot(p.T, p) - np.identity(3))
1✔
1104
    w, v = np.linalg.eig(eta)
1✔
1105
    deformation = 100 * (1.0 / 3.0) * np.sqrt(w[0] ** 2 + w[1] ** 2 + w[2] ** 2)
1✔
1106

1107
    return MagneticDeformation(deformation=deformation, type=type_str)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc