Skip to content

Badelf

Bases: BaseElfAnalysis

Source code in src/baderkit/elf_analysis/badelf/badelf.py
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
class Badelf(BaseElfAnalysis):

    spin_system = "total"

    _method_kwargs = [
        "partition_method",
    ]

    _atom_results = [
        "atom_charges",
        "atom_volumes",
        "oxidation_states",
        "species",
        "min_surface_distances",
        "avg_surface_distances",
        "maxima_elf_values",
        "total_electron_number",
    ]

    _nna_results = [
        "nna_dimensionality",
        "all_nna_dims",
        "all_nna_dim_cutoffs",
        "num_nnas",
        "nna_formula",
        "nnas_per_formula",
        "nnas_per_reduced_formula",
        "nna_structure",
    ]

    _nonsummary_results = [
        "partitioning_planes",
        "atom_labels",
        "elf_radii",
        "labeler",
        "bader",
    ]

    _reset_props = _atom_results + _nna_results + _nonsummary_results

    _summary_props = [
        "atom_results",
        "nna_results",
    ]

    _sub_methods = ["elf_radii", "labeler"]

    def __init__(
        self,
        reference_grid: Grid,
        charge_grid: Grid,
        total_charge_grid: Grid = None,
        partition_method: str | BadelfMethod = BadelfMethod.default,
        shared_feature_splitting_method: Literal[
            "weighted_dist", "pauling", "equal", "dist", "nearest"
        ] = "weighted_dist",
        **kwargs,
    ):
        """
        Class for performing charge analysis using the electron localization function
        (ELF). For information on specific methods, see our [docs](https://sweav02.github.io/baderkit/).

        This class is designed only for single spin or total spin charge densities
        and ELF. For spin-dependent systems, use the SpinBadelf class instead.

        Parameters
        ----------
        reference_grid : Grid
            A Grid like object used for partitioning the unit cell volume. Should
            contain the ELF, ELI-D, LOL, or something similar.
        charge_grid : Grid
            A Grid like object used for summing charge. Should contain the charge
            density.
        total_charge_grid : Grid
            A Grid like object used for locating the vacuum. Should be set when using
            pseudopotential codes such as VASP.
        partition_method : Literal["badelf", "voronelf", "zero-flux"], optional
            The method to use for partitioning nnas from the nearby
            atoms.
                'badelf' (default)
                    Separates nnas using zero-flux surfaces then uses
                    planes at atom radii to separate atoms. This may give more reasonable
                    results for atoms, particularly in ionic solids. Radii are
                    calculated directly from the ELF.
                'voronelf'
                    Separates both nnas and atoms using planes at atomic/nna
                    radii. This is not recommended for nnas that are not
                    spherical, but may provide better results for those that are.
                    Radii are calculated directly from the ELF.
                'zero-flux'
                    Separates nnas and atoms using zero-flux surface. This
                    is the most traditional ELF analysis, but may display some
                    bias towards atoms with higher ELF values. Results for nna
                    sites are identical to BadELF, and the method can be significantly
                    faster.
        shared_feature_splitting_method : Literal["pauling", "equal", "dist", "nearest"], optional
            The method of assigning charge from shared ELF features
            such as covalent or metallic bonds. This parameter is only used with the
            zero-flux method.
                'weighted_dist' (default)
                    Fraction increases with decreasing distance to each atom. The
                    fraction is further weighted by the radius of each atom
                    calculated from the ELF
                'pauling'
                    Distributes charge to neighboring atoms (calculated using CrystalNN)
                    based on the pualing electronegativity of each species normalized
                    such that their sum is equal to 1. If no EN is found for the
                    atom a default of 2.2 is used (including for nnas).
                'equal'
                    Charge is distributed equaly to each neighboring atom/nna
                    (calculated using CrystalNN)
                'dist'
                    Charge is distributed such that more charge is given to the
                    closest atoms. Portions are determined by normalizing the sum
                    of (1/dist) to each neighboring atom.
                'nearest'
                    Gives all charge to the nearest atom or nna site.
        kwargs : dict, optional
            Any keywords to feed to the ElfRadii/ElfLabeler classes
        """

        # ensure the method is valid
        valid_methods = [m.value for m in BadelfMethod]
        if isinstance(partition_method, BadelfMethod):
            self._partition_method = partition_method
        elif partition_method in valid_methods:
            self._partition_method = BadelfMethod(partition_method)
        else:
            raise ValueError(
                f"Invalid method '{partition_method}'. Available options are: {valid_methods}"
            )

        self._shared_feature_splitting_method = shared_feature_splitting_method
        self._kwargs = kwargs

        super().__init__(
            charge_grid=charge_grid,
            total_charge_grid=total_charge_grid,
            reference_grid=reference_grid,
            **kwargs,
        )

    ###########################################################################
    # Settings
    ###########################################################################

    @property
    def partition_method(self) -> str:
        """

        Returns
        -------
        str
            The method to use for partitioning nnas from the nearby
            atoms.
                'badelf'
                    Separates nnas using zero-flux surfaces then uses
                    planes at atom radii to separate atoms. This may give more reasonable
                    results for atoms, particularly in ionic solids. Radii are
                    calculated directly from the ELF.
                'voronelf'
                    Separates both nnas and atoms using planes at atomic/nna
                    radii. This is not recommended for nnas that are not
                    spherical, but may provide better results for those that are.
                    Radii are calculated directly from the ELF.
                'zero-flux'
                    Separates nnas and atoms using zero-flux surface. This
                    is the most traditional ELF analysis, but may display some
                    bias towards atoms with higher ELF values. Results for nna
                    sites are identical to BadELF, and the method can be significantly
                    faster.

        """
        return self._partition_method

    @partition_method.setter
    def partition_method(self, value: str | BadelfMethod):
        valid_methods = [m.value for m in BadelfMethod]
        if isinstance(value, BadelfMethod):
            self._partition_method = value
        elif value in BadelfMethod:
            self._partition_method = BadelfMethod(value)
        else:
            raise ValueError(
                f"Invalid method '{value}'. Available options are: {valid_methods}"
            )
        self._reset_properties()

    @property
    def shared_feature_splitting_method(self) -> str:
        """

        Returns
        -------
        str
            The method of assigning charge from shared ELF features
            such as covalent or metallic bonds. This parameter is only used with the
            zero-flux method.
                'weighted_dist' (default)
                    Fraction increases with decreasing distance to each atom. The
                    fraction is further weighted by the radius of each atom
                    calculated from the ELF
                'pauling'
                    Distributes charge to neighboring atoms (calculated using CrystalNN)
                    based on the pualing electronegativity of each species normalized
                    such that their sum is equal to 1. If no EN is found for the
                    atom a default of 2.2 is used (including for nnas).
                'equal'
                    Charge is distributed equaly to each neighboring atom/nna
                    (calculated using CrystalNN)
                'dist'
                    Charge is distributed such that more charge is given to the
                    closest atoms. Portions are determined by normalizing the sum
                    of (1/dist) to each neighboring atom.
                'nearest'
                    Gives all charge to the nearest atom or nna site.


        """
        return self._shared_feature_splitting_method

    @shared_feature_splitting_method.setter
    def shared_feature_splitting_method(self, value: str):
        self._shared_feature_splitting_method = value
        self._reset_properties()

    ###########################################################################
    # Convenience Properites
    ###########################################################################

    @property
    def bader(self) -> Bader:
        """

        Returns
        -------
        Bader
            The Bader class used to partition the ELF.

        """
        if self._bader is None:
            self._bader = self.labeler.elf_bader
        return self._bader

    @property
    def labeler(self) -> ElfLabeler:
        """

        Returns
        -------
        ElfLabeler
            The ElfLabeler class used to locate non-nuclear attractors.

        """
        if self._labeler is None:
            if self.elf_radii is not None:
                self._labeler = self.elf_radii.labeler
            else:
                self._labeler = ElfLabeler(
                    charge_grid=self.charge_grid,
                    total_charge_grid=self.total_charge_grid,
                    reference_grid=self.reference_grid,
                    cnn_kwargs=None,
                    **self._kwargs,
                )
        return self._labeler

    @property
    def elf_radii(self) -> ElfRadii:
        """

        Returns
        -------
        ElfRadii
            The ElfRadii class used to calculate radii in the system.

        """
        if self._elf_radii is None:
            if self.partition_method == "badelf":
                include_nnas = False
            else:
                include_nnas = True

            self._elf_radii = ElfRadii(
                charge_grid=self.charge_grid,
                total_charge_grid=self.total_charge_grid,
                reference_grid=self.reference_grid,
                include_nnas=include_nnas,
                **self._kwargs,
            )
        return self._elf_radii

    @property
    def nna_structure(self) -> Structure:
        """

        Returns
        -------
        Structure
            The original structure of the system with dummy atoms representing
            non-nuclear attractors appended at the end. Useful when anlyzing
            electride systems for example.

        """
        return self.labeler.nna_structure

    @property
    def num_nnas(self) -> int:
        """

        Returns
        -------
        int
            The number of nna sites (nna maxima) present in the system.

        """
        return self.labeler.num_nnas

    @property
    def species(self) -> list[str]:
        """

        Returns
        -------
        list[str]
            The species of each atom/dummy atom in the nna structure. Covalent
            and metallic features are not included.

        """
        return self.labeler.species

    ###########################################################################
    # Properties
    ###########################################################################

    @property
    def atom_charges(self) -> NDArray:
        """

        Returns
        -------
        NDArray
            The charge associated with each atom and nna site in the system.

        """
        if self._atom_charges is None:
            self._get_voxel_assignments()
        return self._atom_charges.round(10)

    @property
    def atom_volumes(self) -> NDArray:
        """

        Returns
        -------
        NDArray
            The volume associated with each atom and nna site in the system.

        """
        if self._atom_volumes is None:
            self._get_voxel_assignments()
        return self._atom_volumes.round(10)

    @property
    def maxima_elf_values(self) -> NDArray:
        """

        Returns
        -------
        NDArray
            The maximum ELF value for each atom and nna in the system.

        """
        if self._maxima_elf_values is None:
            mapping = self.elf_radii.label_atom_map
            basin_types = self.labeler.basin_types
            max_vals = self.bader.maxima_ref_values
            final_vals = np.zeros(len(self.nna_structure), dtype=np.float64)
            total_nnas = 0
            for i in range(len(max_vals)):
                if basin_types[i] == FeatureType.nna.value:
                    atom = total_nnas + len(self.structure)
                    total_nnas += 1
                else:
                    atom = mapping[i]
                if max_vals[i] > final_vals[atom]:
                    final_vals[atom] = max_vals[i]
            self._maxima_elf_values = final_vals

        return self._maxima_elf_values

    @property
    def partitioning_planes(self) -> tuple | None:
        """

        Returns
        -------
        tuple | None
            The partitioning planes for each site in the structure as a tuple of
            arrays. The first array is the site the plane belongs to, the second
            is a point on the plane, and the third is the vector normal to the plane.
            None if the zero-flux method is selected.

        """

        if self.partition_method == "zero-flux":
            return None, None, None

        if self._partitioning_planes is None:
            # logging.info("Finding partitioning planes")

            # get bonding information
            site_indices = self.elf_radii.site_indices
            # neigh_indices = bond_pairs[:,1]
            plane_points, plane_vectors = self.elf_radii.voronoi_planes

            # we want to transform our planes to the 26 nearest neighbor cells
            # to ensure that we cover our unit cell.
            # For speed, we can remove planes that contain the entire unit cell
            # and we can remove a full set of planes if none of them contain any
            # part of the unit cell.
            # Finally, we can sort each plane by how much of the unit cell it slices
            # such that planes that are likely to reject a grid point come first

            # first we get wrapped planes
            (
                site_indices,
                transforms,
                plane_points,
                plane_vectors,
                plane_atom_volumes,
            ) = get_cell_wrapped_voronoi(
                site_indices=site_indices,
                plane_points=plane_points,
                plane_vectors=plane_vectors,
            )

            # sort planes by site, transform, and volume.
            combined_sort = np.column_stack(
                (plane_atom_volumes, transforms, site_indices)
            )
            sorted_indices = np.lexsort(combined_sort.T)
            transforms = transforms[sorted_indices]
            plane_points = plane_points[sorted_indices]
            plane_vectors = plane_vectors[sorted_indices]
            plane_atom_volumes = plane_atom_volumes[sorted_indices]

            # get plane equations in cartesian coordinates
            plane_vectors = self.reference_grid.frac_to_cart(plane_vectors)
            plane_points = self.reference_grid.frac_to_cart(plane_points)

            # normalize vectors
            plane_vectors = (plane_vectors.T / np.linalg.norm(plane_vectors, axis=1)).T

            # calculate plane equations
            b = -np.einsum("ij,ij->i", plane_vectors, plane_points)

            plane_equations = np.column_stack((plane_vectors, b))

            self._partitioning_planes = (
                site_indices,
                transforms,
                plane_equations,
            )
        return self._partitioning_planes

    @property
    def atom_labels(self) -> NDArray:
        """

        Returns
        -------
        NDArray
            A 3D array with the same shape as the charge grid indicating
            which atom/nna each grid point is assigned to.

        """
        if self._atom_labels is None:
            self._get_voxel_assignments()
        return self._atom_labels

    @property
    def all_nna_dims(self) -> list | None:
        """

        Returns
        -------
        list
            The possible dimensions the nna takes on from an ELF value of
            0 to 1. If no nnas are present the value will be None.

        """
        if self._all_nna_dims is None:
            self._get_nna_dimensionality()
        # if there are no nnas we want to return None, but we don't want
        # to rerun the search each time. I mark the dims as -1 to avoid this
        if self._all_nna_dims == -1:
            return None
        return self._all_nna_dims

    @property
    def all_nna_dim_cutoffs(self) -> list:
        """

        Returns
        -------
        list
            The highest ELF value where each dimensionality in the "all_nna_dims"
            property exists.

        """
        if self._all_nna_dim_cutoffs is None:
            self._get_nna_dimensionality()
        if self._all_nna_dim_cutoffs == -1:
            return None
        return self._all_nna_dim_cutoffs

    @property
    def nna_dimensionality(self) -> int:
        """

        Returns
        -------
        int
            The dimensionality of the nna volume at a value of 0 ELF.

        """
        if self._nna_dimensionality is None and self.all_nna_dims is not None:
            self._nna_dimensionality = self.all_nna_dims[0]

        return self._nna_dimensionality

    @property
    def oxidation_states(self) -> NDArray[np.float64]:

        if not self.valence_counts:
            return None
        if self._oxidation_states is None:
            oxi_state_data = []
            for site, site_charge in zip(self.nna_structure, self.atom_charges):
                element_str = site.specie.symbol
                oxi_state = self.valence_counts.get(element_str, 0.0) - site_charge
                oxi_state_data.append(oxi_state)
            self._oxidation_states = np.array(oxi_state_data)

        return self._oxidation_states

    @property
    def min_surface_distances(self) -> NDArray:
        """

        Returns
        -------
        NDArray
            The minimum distance from each atom or nna center to the partioning
            surface.

        """
        if self._min_surface_distances is None:
            self._get_min_avg_surface_dists()
        return self._min_surface_distances.round(10)

    @property
    def avg_surface_distances(self) -> NDArray:
        """

        Returns
        -------
        NDArray
            The average distance from each atom or nna center to the partitioning
            surface.

        """
        if self._avg_surface_distances is None:
            self._get_min_avg_surface_dists()
        return self._avg_surface_distances.round(10)

    @property
    def nnas_per_formula(self) -> float:
        """

        Returns
        -------
        float
            The number of nna electrons for the full structure formula.

        """
        return self.labeler.nnas_per_formula

    @property
    def nnas_per_reduced_formula(self) -> float:
        """

        Returns
        -------
        float
            The number of electrons in the reduced formula of the structure.

        """
        return self.labeler.nnas_per_reduced_formula

    @property
    def nna_formula(self) -> str:
        """

        Returns
        -------
        str
            A string representation of the nna formula, rounding partial charge
            to the nearest integer.

        """
        return f"{self.structure.formula} e{round(self.nnas_per_formula)}"

    @property
    def total_electron_number(self) -> float:
        """

        Returns
        -------
        float
            The total number of electrons in the system calculated from the
            atom charges and vacuum charge. If this does not match the true
            total electron number within reasonable floating point error,
            there is a major problem.

        """

        return round(self.atom_charges.sum() + self.vacuum_charge, 10)

    @property
    def total_volume(self):
        """

        Returns
        -------
        float
            The total volume integrated in the system. This should match the
            volume of the structure. If it does not there may be a serious problem.

        """

        return round(self.atom_volumes.sum() + self.vacuum_volume, 10)

    ###########################################################################
    # methods
    ###########################################################################

    @staticmethod
    def all_methods() -> list[str]:
        """

        Returns
        -------
        list[str]
            A list of the available methods.

        """

        return [i.value for i in BadelfMethod]

    def _get_zero_flux_assignments(self) -> tuple[NDArray]:
        """
        Assign charge from each feature to their associated atoms. For features
        that have multiple neighbors (e.g. covalent bonds), several options
        are provided for how to divide the charge to nearby neighbors.
        If use_electrides is set to True, features with electride like character
        will be treated as atoms and

        Parameters
        ----------
        splitting_method : Literal["equal", "pauling", "dist", "weighted_dist", "nearest"], optional
            The method used to divide charge and volume of shared features
            betweeen their coordinating atoms.

                'weighted_dist' (default)
                    Fraction increases with decreasing distance to each atom. The
                    fraction is further weighted by the radius of each atom
                    calculated from the ELF
                'pauling'
                    Fraction increases with decreasing pauling electronegativity.
                    If an atom has no recorded EN a value of 2.2 is used which
                    may be incorrect in many cases.
                'equal'
                    Each neighboring atom receives an equal fraction.
                'dist'
                    Fraction increases with decreasing distance to the center
                    of each atom
                'nearest'
                    All charge is assigned to the features nearest atom.

        use_electrides : bool, optional
            If True, features labeled as bare electrons will be treated as electride
            atoms. They will receive partial charge from other shared features
            and their charge/volume will be appended after the atoms'.

        Returns
        -------
        tuple[NDArray]
            Two arrays representing the charges and volumes respectively.

        """
        structure = self.labeler.nna_structure
        mapping = self.elf_radii.label_atom_map
        splitting_method = self.shared_feature_splitting_method

        # create an array to store atom charges and volumes
        atom_charge = np.zeros(len(structure), dtype=np.float64)
        atom_volume = np.zeros(len(structure), dtype=np.float64)

        # if using pauling, get all electronegativities
        if splitting_method == "pauling":
            pauling_ens = np.array([i.specie.X for i in structure])
            pauling_ens = np.nan_to_num(pauling_ens, nan=2.2)

        cnn = CrystalNN(
            distance_cutoffs=None,
            x_diff_weight=0.0,
            porous_adjustment=False,
        )

        for basin_idx in range(len(self.labeler.basin_types)):
            charge = self.bader.basin_charges[basin_idx]
            volume = self.bader.basin_volumes[basin_idx]
            # get atom label
            label = mapping[basin_idx]
            # Labels under the structures length belong to a single atom.
            if label < len(structure):
                atom_charge[label] += charge
                atom_volume[label] += volume
                continue
            # otherwise, this is a shared basin
            temp_structure = structure.copy()
            temp_structure.append("H", coords=self.labeler.maxima_frac[basin_idx])
            coord_sites = cnn.get_nn(temp_structure, n=len(structure))
            coord_atoms = [i.index for i in coord_sites]

            # get unique atoms and counts (correction for small cells)
            unique_atoms, unique_indices, atom_counts = np.unique(
                coord_atoms, return_index=True, return_counts=True
            )

            if len(coord_atoms) == 0:
                # This shouldn't happen, but could if CrystalNN failed
                # to find neighbors.
                logging.warning(
                    f"No neighboring atoms found for feature with index {basin_idx}. Feature assigned to nearest atom."
                )
                # assign all charge/volume to the closest atom
                nearest = np.argmin(temp_structure.distance_matrix[len(structure)])
                atom_charge[nearest] += charge
                atom_volume[nearest] += volume

            elif len(coord_atoms) == 1:
                # all methods will add charge and volume to this atom.
                # assigning it here potentially avoids divide by zeros for
                # core features
                atom_charge[coord_atoms[0]] += charge
                atom_volume[coord_atoms[0]] += volume

            elif splitting_method == "equal":
                # evenly split the feature to each neighboring atom
                atom_charge[unique_atoms] += (charge / len(coord_atoms)) * atom_counts
                atom_volume[unique_atoms] += (volume / len(coord_atoms)) * atom_counts

            elif splitting_method == "pauling":
                # get the pauling ens for coordinated atoms
                ens = pauling_ens[coord_atoms]
                # normalize to the total en
                ens /= ens.sum()
                # get the weights for each unique atom
                ens = ens[unique_indices]
                atom_charge[unique_atoms] += charge * ens * atom_counts
                atom_volume[unique_atoms] += volume * ens * atom_counts

            elif splitting_method == "dist":
                # get the dist to each coordinated atom
                all_dists = temp_structure.distance_matrix[len(structure)]
                dists = all_dists[coord_atoms]
                # invert and normalize
                dists = 1 / dists
                dists /= dists.sum()
                # add for each atom
                for coord_idx, atom in enumerate(coord_atoms):
                    atom_charge[atom] += charge * dists[coord_idx]
                    atom_volume[atom] += volume * dists[coord_idx]

            elif splitting_method == "weighted_dist":
                # get the dist to each coordinated atom
                all_dists = temp_structure.distance_matrix[len(structure)]
                dists = all_dists[coord_atoms]
                atom_radii = self.elf_radii.atom_radii[coord_atoms]

                # calculate the weighted contribution to each atom and normalize
                weight = atom_radii / dists
                weight /= weight.sum()
                # add for each atom
                for coord_idx, atom in enumerate(coord_atoms):
                    atom_charge[atom] += charge * weight[coord_idx]
                    atom_volume[atom] += volume * weight[coord_idx]

            elif splitting_method == "nearest":
                # assign all charge/volume to the closest atom
                # get the dist to each coordinated atom
                all_dists = temp_structure.distance_matrix[len(structure)]
                dists = all_dists[coord_atoms]
                nearest = np.argmin(dists)
                atom_charge[nearest] += charge
                atom_volume[nearest] += volume
            else:
                raise ValueError(
                    f"'{splitting_method}' is not a valid splitting method"
                )

        return atom_charge.round(10), atom_volume.round(10)

    def _get_voxel_assignments(self) -> None:
        """

        Returns
        -------
        None
            Gets a dataframe of voxel assignments. The dataframe has columns
            [x, y, z, charge, sites].

        """
        # make sure we've run our partitioning (for logging clarity)
        (
            site_indices,
            site_transforms,
            plane_equations,
        ) = self.partitioning_planes

        logging.info("Beginning voxel assignment")

        if self.partition_method == "zero-flux":
            # we are done here and can assign charges/volumes immediately
            (
                atom_labels,
                atom_images,
                atom_charges,
                atom_volumes,
                basin_atoms,
                basin_atom_dists,
            ) = self.bader.assign_basins_to_structure(self.nna_structure)
            self._atom_labels = atom_labels
            self._atom_images = atom_images
            self._atom_charges = atom_charges
            self._atom_volumes = atom_volumes

        else:
            # get bader basin labels
            basin_labels = self.bader.maxima_basin_labels
            num_basins = len(self.labeler.maxima_frac)
            structure_len = len(self.labeler.nna_structure)
            # get vacuum
            vacuum = basin_labels == num_basins
            # initialize badelf labels
            labels = np.full(
                basin_labels.shape, structure_len, dtype=basin_labels.dtype
            )

            # create a mask at nna indices
            if self.partition_method == "badelf":
                # create map from basin index to nna structure index
                nna_indices = self.labeler.nna_indices
                label_map = np.empty(len(self.labeler.maxima_frac), dtype=np.int64)
                label_map[nna_indices] = np.arange(len(nna_indices)) + len(
                    self.structure
                )

                # get labels at electride sites
                electride_mask = np.isin(basin_labels, nna_indices)
                labels[electride_mask] = label_map[basin_labels[electride_mask]]
            else:
                electride_mask = np.zeros(labels.shape, dtype=np.bool_)

            # get mask to ignore
            exclude_mask = vacuum | electride_mask

            # calculate the maximum distance in fractional coords from the center of
            # a voxel to its edges
            voxel_dist = self.reference_grid.max_point_dist + 1e-12

            # get the transforms within a set radius
            min_radius = voxel_dist * 2
            max_radius = (np.array(self.structure.lattice.abc) / 2).min()
            max_radius = min(max_radius, 3.0)  # cap radius for large cells
            sphere_transforms, transform_dists = (
                self.reference_grid.get_radial_neighbor_transforms(r=max_radius)
            )
            valid_mask = transform_dists >= min_radius
            sphere_transforms = sphere_transforms[np.where(valid_mask)[0]]
            transform_dists = transform_dists[valid_mask]

            # get the indices at which new transform dists occur
            transform_breaks = np.where(transform_dists[:-1] != transform_dists[1:])[0]

            # Now calculate labels, charges, and volumes assigned to each feature
            labels, charges, volumes = get_badelf_assignments(
                data=self.charge_grid.total,
                labels=labels,
                site_indices=site_indices,
                site_transforms=site_transforms,
                plane_equations=plane_equations,
                exclude_mask=exclude_mask,
                min_plane_dist=voxel_dist,
                lattice_matrix=self.reference_grid.matrix,
                sphere_transforms=sphere_transforms,
                transform_dists=transform_dists,
                transform_breaks=transform_breaks,
                max_val=structure_len,
            )

            # convert charges/volumes to correct units
            charges /= self.charge_grid.ngridpts
            volumes = volumes * self.structure.volume / self.charge_grid.ngridpts

            # write feature charges/volumes
            self._atom_labels = labels
            self._atom_charges = charges
            self._atom_volumes = volumes

        logging.info("Finished voxel assignment")

    def _get_ELF_dimensionality(
        self,
        nna_mask: NDArray,
        cutoff: float,
    ) -> int:
        """

        This algorithm works by checking if the voxels with values above the cutoff
        are connected to the equivalent voxel in the unit cell one transformation
        over. This is done primarily using scipy.ndimage.label which determines
        which voxels are connected. To do this rigorously, the unit cell is repeated
        to make a (2,2,2) super cell and the connections are checked going from
        the original unit cell to the unit cells connected at the faces, edges,
        and corners. If a connection in that direction is found, the total number
        of connections increases. Dimensionalities of 0,1,2, and 3 are represented
        by 0,1,4,and 13 connections respectively.

        NOTE: This can be made much faster with numba using an algorithm similar
        to that used in BaderKit to determine which atoms are surrounded. However,
        this would require a lot of time.

        Parameters
        ----------
        nna_mask : np.array
            The ELF Grid object with only values associated with nnas.
        cutoff : float
            The minimum elf value to consider as a connection.

        Returns
        -------
        int
            The dimensionality at the ELF cutoff.

        """
        # Remove data below our cutoff
        mask = nna_mask & (self.reference_grid.total >= cutoff)

        # if we have no features, return 0 immediately
        if not np.any(mask):
            return 0

        # get the features that sit in the mask at this value
        feature_indices = self.labeler.nna_structure.frac_coords[len(self.structure) :]
        feature_indices = (
            np.round(self.charge_grid.frac_to_grid(feature_indices)).astype(int)
            % self.reference_grid.shape
        )
        # only use indices that are not 0
        feature_indices = [i for i in feature_indices if mask[i[0], i[1], i[2]]]

        # if we have no nna features in the mask, immediately return 0
        if len(feature_indices) == 0:
            return 0

        # create a supercell mask and label it
        supercell_mask = np.tile(mask, [2, 2, 2])
        labels, num_features = label(supercell_mask, structure=np.ones([3, 3, 3]))

        # We are going to need to translate the above voxels and the entire unit
        # cell so we create a list of desired transformations
        transformations = [
            [0, 0, 0],  # -
            [1, 0, 0],  # x
            [0, 1, 0],  # y
            [0, 0, 1],  # z
            [1, 1, 0],  # xy
            [1, 0, 1],  # xz
            [0, 1, 1],  # yz
            [1, 1, 1],  # xyz
        ]
        transformations = np.array(transformations)
        transformations = self.charge_grid.frac_to_grid(transformations)

        # The unit cell can be connected to neighboring unit cells in 26 directions.
        # however, we only need to consider half of these as the others are symmetrical.
        connections = [
            # surfaces (3)
            [0, 1],  # x
            [0, 2],  # y
            [0, 3],  # z
            # edges (6)
            [0, 4],  # xy
            [0, 5],  # xz
            [0, 6],  # yz
            [3, 1],  # x-z
            [3, 2],  # y-z
            [1, 2],  # -xy
            # corners (4)
            [0, 7],  # x,y,z
            [1, 6],  # -x,y,z
            [2, 5],  # x,-y,z
            [3, 4],  # x,y,-z
        ]
        # Using these connections we can determine the dimensionality of the system.
        # 1 connection is 1D, 2-4 connections is 2D and 5-13 connections is 3D.
        # !!! These may need to be updated if I'm wrong. The idea comes from
        # the fact that the connections should be 1, 4, and 13, but sometimes
        # voxelation issues result in a connection not working in one direction
        # while it would in the reverse direction (which isn't possible with
        # true symmetry). The range accounts for this possibility. The problem
        # might be if its possible to have for example a 2D connecting structure
        # with 5 connections. However, I'm pretty sure that immediately causes
        # an increase to 3D dimensionality.
        # First we create a list to store potential dimensionalites based off of
        # each feature. We will take the highest dimensionality.
        dimensionalities = []
        for coord in feature_indices:
            # get the labels at each transformation
            trans_labels = []
            for trans in transformations:
                x, y, z = trans + coord
                trans_labels.append(labels[x, y, z])

            # count number of connections
            connections_num = 0
            for connection in connections:
                # get the feature label at each voxel
                label1 = trans_labels[connection[0]]
                label2 = trans_labels[connection[1]]
                # If the labels are the same, the unit cell is connected in this
                # direction
                if label1 == label2:
                    connections_num += 1
            if connections_num == 0:
                dimensionalities.append(0)
            elif connections_num == 1:
                dimensionalities.append(1)
            elif 1 < connections_num <= 4:
                dimensionalities.append(2)
            elif 5 < connections_num <= 13:
                dimensionalities.append(3)

        return max(dimensionalities)

    def _get_nna_dimensionality(self) -> None:
        """

        Gets the nna dimensionalities and range of ELF values that they
        exist at.

        """
        # TODO: This whole method should probably be rewritten in Numba
        # If we have no nnas theres no reason to continue so we stop here
        logging.info("Finding nna dimensionality cutoffs")
        if self.num_nnas == 0:
            self._all_nna_dims = -1
            self._all_nna_dim_cutoffs = -1

        ###############################################################################
        # This section preps an ELF grid that only contains values from the nna
        # sites and is zero everywhere else.
        ###############################################################################

        # Create a mask at nnas
        nna_indices = [
            i for i in range(len(self.structure), len(self.labeler.nna_structure))
        ]
        # NOTE: even if we have shared features, these indices are still correct
        # so long as the nna sites come first
        nna_mask = np.isin(self.atom_labels, nna_indices)

        #######################################################################
        # This section scans across different cutoffs to determine what dimensionalities
        # exist in the nna ELF
        #######################################################################
        logging.info("Calculating dimensionality at 0 ELF")
        highest_dimension = self._get_ELF_dimensionality(nna_mask, 0)
        dimensions = [i for i in range(0, highest_dimension)]
        dimensions.reverse()
        logging.info(f"Max nna dimensionality: {highest_dimension}")
        # Create lists for the refined dimensions
        final_dimensions = [highest_dimension]
        final_connections = [0]
        amounts_to_change = []
        # We refine by guessing the cutoff is 0.5 then increasing or decreasing by
        # 0.25, then 0.125 etc. down to 0.000015259.
        for i in range(1, 16):
            amounts_to_change.append(1 / (2 ** (i + 1)))
        for dimension in dimensions:
            guess = 0.5
            # assume this dimension is not found
            found_dimension = False
            logging.info(f"Refining cutoff for dimension {dimension}")
            for i in tqdm(amounts_to_change, total=len(amounts_to_change)):
                # check what our current dimension is. If we are at a higher dimension
                # we need to raise the cutoff. If we are at a lower dimension or at
                # the dimension we need to lower it
                current_dimension = self._get_ELF_dimensionality(nna_mask, guess)
                if current_dimension > dimension:
                    guess += i
                elif current_dimension < dimension:
                    guess -= i
                elif current_dimension == dimension:
                    # We have found the dimension so we add it to our lists.
                    guess -= i
                    found_dimension = True
            if found_dimension:
                final_connections.append(round(guess, 4))
                final_dimensions.append(dimension)
        self._all_nna_dims = final_dimensions
        self._all_nna_dim_cutoffs = final_connections

    def _get_min_avg_surface_dists(self) -> None:
        """

        Calculates the minimum and average distance from each atom and nna
        to the partitioning surface.

        """

        neigh_transforms, _ = self.charge_grid.point_neighbor_transforms
        edges = get_edges(
            labeled_array=self.atom_labels,
            neighbor_transforms=neigh_transforms,
            vacuum_label=-1,
        )

        self._min_surface_distances, self._avg_surface_distances = (
            get_min_avg_surface_dists(
                labels=self.atom_labels,
                frac_coords=self.labeler.nna_structure.frac_coords,
                edge_mask=edges,
                matrix=self.charge_grid.matrix,
                max_value=np.max(self.structure.lattice.abc) * 2,
            )
        )

    ###########################################################################
    # Write Methods
    ###########################################################################

    def write_atom_volumes(
        self,
        atom_indices: NDArray,
        filename: str | Path = "ELFCAR",
        write_grid: str = "reference_grid",
        **kwargs,
    ):
        """
        Writes atomic basins to vasp-like files. Points belonging to the atom
        will have values from the charge or reference grid, and all other points
        will be 0.

        Parameters
        ----------
        atom_indices : NDArray
            The list of atom indices to write

        """

        for atom_index in atom_indices:
            # get a mask at the requested atoms
            mask = self.atom_labels == atom_index
            if not "suffix" in kwargs.keys():
                kwargs["suffix"] = f"_a{atom_index}"
            self._write_volume(
                volume_mask=mask, write_grid=write_grid, filename=filename, **kwargs
            )

    def write_all_atom_volumes(
        self,
        **kwargs,
    ):
        """
        Writes all atomic basins to vasp-like files. Points belonging to the atom
        will have values from the charge or reference grid, and all other points
        will be 0.

        """
        atom_indices = np.array(range(len(self.structure)))
        self.write_atom_volumes(
            atom_indices=atom_indices,
            **kwargs,
        )

    def write_atom_volumes_sum(
        self,
        atom_indices: NDArray,
        filename: str | Path = "ELFCAR",
        write_grid: str = "reference_grid",
        **kwargs,
    ):
        """
        Writes the union of the provided atom basins to vasp-like files.
        Points belonging to the atoms will have values from the charge or
        reference grid, and all other points will be 0.

        Parameters
        ----------
        atom_indices : NDArray
            The list of atom indices to sum and write

        """

        mask = np.isin(self.atom_labels, atom_indices)
        # write
        if not "suffix" in kwargs.keys():
            kwargs["suffix"] = "_asum"
        self._write_volume(
            volume_mask=mask, write_grid=write_grid, filename=filename, **kwargs
        )

    def write_species_volume(
        self,
        species: str,
        filename: str | Path = "ELFCAR",
        write_grid: str = "reference_grid",
        **kwargs,
    ):
        """
        Writes the charge density or reference file for all atoms of the given
        species to a single file.

        Parameters
        ----------
        species : str, optional
            The species to write.

        """

        # add dummy atoms if desired
        indices = self.structure.indices_from_symbol(species)

        # Get mask where the grid belongs to requested species
        mask = np.isin(self.atom_labels, indices)
        if not "suffix" in kwargs.keys():
            kwargs["suffix"] = f"_{species}"
        self._write_volume(
            volume_mask=mask, write_grid=write_grid, filename=filename, **kwargs
        )

    def get_atom_results_dataframe(self) -> pd.DataFrame:
        """
        Collects a summary of results for the atoms in a pandas DataFrame.

        Returns
        -------
        atoms_df : pd.DataFrame
            A table summarizing the atomic basins.

        """
        # Get atom results summary
        atom_frac_coords = self.nna_structure.frac_coords
        atoms_df = pd.DataFrame(
            {
                "label": self.nna_structure.labels,
                "x": atom_frac_coords[:, 0],
                "y": atom_frac_coords[:, 1],
                "z": atom_frac_coords[:, 2],
                "charge": self.atom_charges,
                "volume": self.atom_volumes,
                "surface_dist": self.min_surface_distances,
            }
        )
        return atoms_df

    def write_atom_tsv(self, filepath: Path | str = "badelf_atoms.tsv"):
        """
        Writes a summary of atom results to .tsv files.

        Parameters
        ----------
        filepath : str | Path
            The Path to write the results to. The default is "bader_atoms.tsv".


        """
        filepath = Path(filepath)

        # Get atom results summary
        atoms_df = self.get_atom_results_dataframe()
        formatted_atoms_df = atoms_df.copy()
        numeric_cols = formatted_atoms_df.select_dtypes(include="number").columns
        formatted_atoms_df[numeric_cols] = formatted_atoms_df[numeric_cols].map(
            lambda x: f"{x:.5f}"
        )

        # Determine max width per column including header
        col_widths = {
            col: max(len(col), formatted_atoms_df[col].map(len).max())
            for col in atoms_df.columns
        }

        # Note what we're writing in log
        logging.info(f"Writing Atom Summary to {filepath}")

        # write output summaries
        with open(filepath, "w") as f:
            # Write header
            header = "\t".join(
                f"{col:<{col_widths[col]}}" for col in formatted_atoms_df.columns
            )
            f.write(header + "\n")

            # Write rows
            for _, row in formatted_atoms_df.iterrows():
                line = "\t".join(
                    f"{val:<{col_widths[col]}}" for col, val in row.items()
                )
                f.write(line + "\n")
            # write vacuum summary to atom file
            f.write("\n")
            f.write(f"Vacuum Charge:\t\t{self.vacuum_charge:.5f}\n")
            f.write(f"Vacuum Volume:\t\t{self.vacuum_volume:.5f}\n")
            f.write(f"Total Electrons:\t{self.total_electron_number:.5f}\n")
            f.write(f"Total Volume:\t{self.total_volume:.5f}\n")

all_nna_dim_cutoffs property

Returns:

Type Description
list

The highest ELF value where each dimensionality in the "all_nna_dims" property exists.

all_nna_dims property

Returns:

Type Description
list

The possible dimensions the nna takes on from an ELF value of 0 to 1. If no nnas are present the value will be None.

atom_charges property

Returns:

Type Description
NDArray

The charge associated with each atom and nna site in the system.

atom_labels property

Returns:

Type Description
NDArray

A 3D array with the same shape as the charge grid indicating which atom/nna each grid point is assigned to.

atom_volumes property

Returns:

Type Description
NDArray

The volume associated with each atom and nna site in the system.

avg_surface_distances property

Returns:

Type Description
NDArray

The average distance from each atom or nna center to the partitioning surface.

bader property

Returns:

Type Description
Bader

The Bader class used to partition the ELF.

elf_radii property

Returns:

Type Description
ElfRadii

The ElfRadii class used to calculate radii in the system.

labeler property

Returns:

Type Description
ElfLabeler

The ElfLabeler class used to locate non-nuclear attractors.

maxima_elf_values property

Returns:

Type Description
NDArray

The maximum ELF value for each atom and nna in the system.

min_surface_distances property

Returns:

Type Description
NDArray

The minimum distance from each atom or nna center to the partioning surface.

nna_dimensionality property

Returns:

Type Description
int

The dimensionality of the nna volume at a value of 0 ELF.

nna_formula property

Returns:

Type Description
str

A string representation of the nna formula, rounding partial charge to the nearest integer.

nna_structure property

Returns:

Type Description
Structure

The original structure of the system with dummy atoms representing non-nuclear attractors appended at the end. Useful when anlyzing electride systems for example.

nnas_per_formula property

Returns:

Type Description
float

The number of nna electrons for the full structure formula.

nnas_per_reduced_formula property

Returns:

Type Description
float

The number of electrons in the reduced formula of the structure.

num_nnas property

Returns:

Type Description
int

The number of nna sites (nna maxima) present in the system.

partition_method property writable

Returns:

Type Description
str

The method to use for partitioning nnas from the nearby atoms. 'badelf' Separates nnas using zero-flux surfaces then uses planes at atom radii to separate atoms. This may give more reasonable results for atoms, particularly in ionic solids. Radii are calculated directly from the ELF. 'voronelf' Separates both nnas and atoms using planes at atomic/nna radii. This is not recommended for nnas that are not spherical, but may provide better results for those that are. Radii are calculated directly from the ELF. 'zero-flux' Separates nnas and atoms using zero-flux surface. This is the most traditional ELF analysis, but may display some bias towards atoms with higher ELF values. Results for nna sites are identical to BadELF, and the method can be significantly faster.

partitioning_planes property

Returns:

Type Description
tuple | None

The partitioning planes for each site in the structure as a tuple of arrays. The first array is the site the plane belongs to, the second is a point on the plane, and the third is the vector normal to the plane. None if the zero-flux method is selected.

shared_feature_splitting_method property writable

Returns:

Type Description
str

The method of assigning charge from shared ELF features such as covalent or metallic bonds. This parameter is only used with the zero-flux method. 'weighted_dist' (default) Fraction increases with decreasing distance to each atom. The fraction is further weighted by the radius of each atom calculated from the ELF 'pauling' Distributes charge to neighboring atoms (calculated using CrystalNN) based on the pualing electronegativity of each species normalized such that their sum is equal to 1. If no EN is found for the atom a default of 2.2 is used (including for nnas). 'equal' Charge is distributed equaly to each neighboring atom/nna (calculated using CrystalNN) 'dist' Charge is distributed such that more charge is given to the closest atoms. Portions are determined by normalizing the sum of (1/dist) to each neighboring atom. 'nearest' Gives all charge to the nearest atom or nna site.

species property

Returns:

Type Description
list[str]

The species of each atom/dummy atom in the nna structure. Covalent and metallic features are not included.

total_electron_number property

Returns:

Type Description
float

The total number of electrons in the system calculated from the atom charges and vacuum charge. If this does not match the true total electron number within reasonable floating point error, there is a major problem.

total_volume property

Returns:

Type Description
float

The total volume integrated in the system. This should match the volume of the structure. If it does not there may be a serious problem.

__init__(reference_grid, charge_grid, total_charge_grid=None, partition_method=BadelfMethod.default, shared_feature_splitting_method='weighted_dist', **kwargs)

Class for performing charge analysis using the electron localization function (ELF). For information on specific methods, see our docs.

This class is designed only for single spin or total spin charge densities and ELF. For spin-dependent systems, use the SpinBadelf class instead.

Parameters:

Name Type Description Default
reference_grid Grid

A Grid like object used for partitioning the unit cell volume. Should contain the ELF, ELI-D, LOL, or something similar.

required
charge_grid Grid

A Grid like object used for summing charge. Should contain the charge density.

required
total_charge_grid Grid

A Grid like object used for locating the vacuum. Should be set when using pseudopotential codes such as VASP.

None
partition_method Literal['badelf', 'voronelf', 'zero-flux']

The method to use for partitioning nnas from the nearby atoms. 'badelf' (default) Separates nnas using zero-flux surfaces then uses planes at atom radii to separate atoms. This may give more reasonable results for atoms, particularly in ionic solids. Radii are calculated directly from the ELF. 'voronelf' Separates both nnas and atoms using planes at atomic/nna radii. This is not recommended for nnas that are not spherical, but may provide better results for those that are. Radii are calculated directly from the ELF. 'zero-flux' Separates nnas and atoms using zero-flux surface. This is the most traditional ELF analysis, but may display some bias towards atoms with higher ELF values. Results for nna sites are identical to BadELF, and the method can be significantly faster.

default
shared_feature_splitting_method Literal['pauling', 'equal', 'dist', 'nearest']

The method of assigning charge from shared ELF features such as covalent or metallic bonds. This parameter is only used with the zero-flux method. 'weighted_dist' (default) Fraction increases with decreasing distance to each atom. The fraction is further weighted by the radius of each atom calculated from the ELF 'pauling' Distributes charge to neighboring atoms (calculated using CrystalNN) based on the pualing electronegativity of each species normalized such that their sum is equal to 1. If no EN is found for the atom a default of 2.2 is used (including for nnas). 'equal' Charge is distributed equaly to each neighboring atom/nna (calculated using CrystalNN) 'dist' Charge is distributed such that more charge is given to the closest atoms. Portions are determined by normalizing the sum of (1/dist) to each neighboring atom. 'nearest' Gives all charge to the nearest atom or nna site.

'weighted_dist'
kwargs dict

Any keywords to feed to the ElfRadii/ElfLabeler classes

{}
Source code in src/baderkit/elf_analysis/badelf/badelf.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def __init__(
    self,
    reference_grid: Grid,
    charge_grid: Grid,
    total_charge_grid: Grid = None,
    partition_method: str | BadelfMethod = BadelfMethod.default,
    shared_feature_splitting_method: Literal[
        "weighted_dist", "pauling", "equal", "dist", "nearest"
    ] = "weighted_dist",
    **kwargs,
):
    """
    Class for performing charge analysis using the electron localization function
    (ELF). For information on specific methods, see our [docs](https://sweav02.github.io/baderkit/).

    This class is designed only for single spin or total spin charge densities
    and ELF. For spin-dependent systems, use the SpinBadelf class instead.

    Parameters
    ----------
    reference_grid : Grid
        A Grid like object used for partitioning the unit cell volume. Should
        contain the ELF, ELI-D, LOL, or something similar.
    charge_grid : Grid
        A Grid like object used for summing charge. Should contain the charge
        density.
    total_charge_grid : Grid
        A Grid like object used for locating the vacuum. Should be set when using
        pseudopotential codes such as VASP.
    partition_method : Literal["badelf", "voronelf", "zero-flux"], optional
        The method to use for partitioning nnas from the nearby
        atoms.
            'badelf' (default)
                Separates nnas using zero-flux surfaces then uses
                planes at atom radii to separate atoms. This may give more reasonable
                results for atoms, particularly in ionic solids. Radii are
                calculated directly from the ELF.
            'voronelf'
                Separates both nnas and atoms using planes at atomic/nna
                radii. This is not recommended for nnas that are not
                spherical, but may provide better results for those that are.
                Radii are calculated directly from the ELF.
            'zero-flux'
                Separates nnas and atoms using zero-flux surface. This
                is the most traditional ELF analysis, but may display some
                bias towards atoms with higher ELF values. Results for nna
                sites are identical to BadELF, and the method can be significantly
                faster.
    shared_feature_splitting_method : Literal["pauling", "equal", "dist", "nearest"], optional
        The method of assigning charge from shared ELF features
        such as covalent or metallic bonds. This parameter is only used with the
        zero-flux method.
            'weighted_dist' (default)
                Fraction increases with decreasing distance to each atom. The
                fraction is further weighted by the radius of each atom
                calculated from the ELF
            'pauling'
                Distributes charge to neighboring atoms (calculated using CrystalNN)
                based on the pualing electronegativity of each species normalized
                such that their sum is equal to 1. If no EN is found for the
                atom a default of 2.2 is used (including for nnas).
            'equal'
                Charge is distributed equaly to each neighboring atom/nna
                (calculated using CrystalNN)
            'dist'
                Charge is distributed such that more charge is given to the
                closest atoms. Portions are determined by normalizing the sum
                of (1/dist) to each neighboring atom.
            'nearest'
                Gives all charge to the nearest atom or nna site.
    kwargs : dict, optional
        Any keywords to feed to the ElfRadii/ElfLabeler classes
    """

    # ensure the method is valid
    valid_methods = [m.value for m in BadelfMethod]
    if isinstance(partition_method, BadelfMethod):
        self._partition_method = partition_method
    elif partition_method in valid_methods:
        self._partition_method = BadelfMethod(partition_method)
    else:
        raise ValueError(
            f"Invalid method '{partition_method}'. Available options are: {valid_methods}"
        )

    self._shared_feature_splitting_method = shared_feature_splitting_method
    self._kwargs = kwargs

    super().__init__(
        charge_grid=charge_grid,
        total_charge_grid=total_charge_grid,
        reference_grid=reference_grid,
        **kwargs,
    )

all_methods() staticmethod

Returns:

Type Description
list[str]

A list of the available methods.

Source code in src/baderkit/elf_analysis/badelf/badelf.py
685
686
687
688
689
690
691
692
693
694
695
696
@staticmethod
def all_methods() -> list[str]:
    """

    Returns
    -------
    list[str]
        A list of the available methods.

    """

    return [i.value for i in BadelfMethod]

get_atom_results_dataframe()

Collects a summary of results for the atoms in a pandas DataFrame.

Returns:

Name Type Description
atoms_df DataFrame

A table summarizing the atomic basins.

Source code in src/baderkit/elf_analysis/badelf/badelf.py
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
def get_atom_results_dataframe(self) -> pd.DataFrame:
    """
    Collects a summary of results for the atoms in a pandas DataFrame.

    Returns
    -------
    atoms_df : pd.DataFrame
        A table summarizing the atomic basins.

    """
    # Get atom results summary
    atom_frac_coords = self.nna_structure.frac_coords
    atoms_df = pd.DataFrame(
        {
            "label": self.nna_structure.labels,
            "x": atom_frac_coords[:, 0],
            "y": atom_frac_coords[:, 1],
            "z": atom_frac_coords[:, 2],
            "charge": self.atom_charges,
            "volume": self.atom_volumes,
            "surface_dist": self.min_surface_distances,
        }
    )
    return atoms_df

write_all_atom_volumes(**kwargs)

Writes all atomic basins to vasp-like files. Points belonging to the atom will have values from the charge or reference grid, and all other points will be 0.

Source code in src/baderkit/elf_analysis/badelf/badelf.py
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
def write_all_atom_volumes(
    self,
    **kwargs,
):
    """
    Writes all atomic basins to vasp-like files. Points belonging to the atom
    will have values from the charge or reference grid, and all other points
    will be 0.

    """
    atom_indices = np.array(range(len(self.structure)))
    self.write_atom_volumes(
        atom_indices=atom_indices,
        **kwargs,
    )

write_atom_tsv(filepath='badelf_atoms.tsv')

Writes a summary of atom results to .tsv files.

Parameters:

Name Type Description Default
filepath str | Path

The Path to write the results to. The default is "bader_atoms.tsv".

'badelf_atoms.tsv'
Source code in src/baderkit/elf_analysis/badelf/badelf.py
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
def write_atom_tsv(self, filepath: Path | str = "badelf_atoms.tsv"):
    """
    Writes a summary of atom results to .tsv files.

    Parameters
    ----------
    filepath : str | Path
        The Path to write the results to. The default is "bader_atoms.tsv".


    """
    filepath = Path(filepath)

    # Get atom results summary
    atoms_df = self.get_atom_results_dataframe()
    formatted_atoms_df = atoms_df.copy()
    numeric_cols = formatted_atoms_df.select_dtypes(include="number").columns
    formatted_atoms_df[numeric_cols] = formatted_atoms_df[numeric_cols].map(
        lambda x: f"{x:.5f}"
    )

    # Determine max width per column including header
    col_widths = {
        col: max(len(col), formatted_atoms_df[col].map(len).max())
        for col in atoms_df.columns
    }

    # Note what we're writing in log
    logging.info(f"Writing Atom Summary to {filepath}")

    # write output summaries
    with open(filepath, "w") as f:
        # Write header
        header = "\t".join(
            f"{col:<{col_widths[col]}}" for col in formatted_atoms_df.columns
        )
        f.write(header + "\n")

        # Write rows
        for _, row in formatted_atoms_df.iterrows():
            line = "\t".join(
                f"{val:<{col_widths[col]}}" for col, val in row.items()
            )
            f.write(line + "\n")
        # write vacuum summary to atom file
        f.write("\n")
        f.write(f"Vacuum Charge:\t\t{self.vacuum_charge:.5f}\n")
        f.write(f"Vacuum Volume:\t\t{self.vacuum_volume:.5f}\n")
        f.write(f"Total Electrons:\t{self.total_electron_number:.5f}\n")
        f.write(f"Total Volume:\t{self.total_volume:.5f}\n")

write_atom_volumes(atom_indices, filename='ELFCAR', write_grid='reference_grid', **kwargs)

Writes atomic basins to vasp-like files. Points belonging to the atom will have values from the charge or reference grid, and all other points will be 0.

Parameters:

Name Type Description Default
atom_indices NDArray

The list of atom indices to write

required
Source code in src/baderkit/elf_analysis/badelf/badelf.py
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
def write_atom_volumes(
    self,
    atom_indices: NDArray,
    filename: str | Path = "ELFCAR",
    write_grid: str = "reference_grid",
    **kwargs,
):
    """
    Writes atomic basins to vasp-like files. Points belonging to the atom
    will have values from the charge or reference grid, and all other points
    will be 0.

    Parameters
    ----------
    atom_indices : NDArray
        The list of atom indices to write

    """

    for atom_index in atom_indices:
        # get a mask at the requested atoms
        mask = self.atom_labels == atom_index
        if not "suffix" in kwargs.keys():
            kwargs["suffix"] = f"_a{atom_index}"
        self._write_volume(
            volume_mask=mask, write_grid=write_grid, filename=filename, **kwargs
        )

write_atom_volumes_sum(atom_indices, filename='ELFCAR', write_grid='reference_grid', **kwargs)

Writes the union of the provided atom basins to vasp-like files. Points belonging to the atoms will have values from the charge or reference grid, and all other points will be 0.

Parameters:

Name Type Description Default
atom_indices NDArray

The list of atom indices to sum and write

required
Source code in src/baderkit/elf_analysis/badelf/badelf.py
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
def write_atom_volumes_sum(
    self,
    atom_indices: NDArray,
    filename: str | Path = "ELFCAR",
    write_grid: str = "reference_grid",
    **kwargs,
):
    """
    Writes the union of the provided atom basins to vasp-like files.
    Points belonging to the atoms will have values from the charge or
    reference grid, and all other points will be 0.

    Parameters
    ----------
    atom_indices : NDArray
        The list of atom indices to sum and write

    """

    mask = np.isin(self.atom_labels, atom_indices)
    # write
    if not "suffix" in kwargs.keys():
        kwargs["suffix"] = "_asum"
    self._write_volume(
        volume_mask=mask, write_grid=write_grid, filename=filename, **kwargs
    )

write_species_volume(species, filename='ELFCAR', write_grid='reference_grid', **kwargs)

Writes the charge density or reference file for all atoms of the given species to a single file.

Parameters:

Name Type Description Default
species str

The species to write.

required
Source code in src/baderkit/elf_analysis/badelf/badelf.py
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
def write_species_volume(
    self,
    species: str,
    filename: str | Path = "ELFCAR",
    write_grid: str = "reference_grid",
    **kwargs,
):
    """
    Writes the charge density or reference file for all atoms of the given
    species to a single file.

    Parameters
    ----------
    species : str, optional
        The species to write.

    """

    # add dummy atoms if desired
    indices = self.structure.indices_from_symbol(species)

    # Get mask where the grid belongs to requested species
    mask = np.isin(self.atom_labels, indices)
    if not "suffix" in kwargs.keys():
        kwargs["suffix"] = f"_{species}"
    self._write_volume(
        volume_mask=mask, write_grid=write_grid, filename=filename, **kwargs
    )