kim-tools 0.3.6__py3-none-any.whl → 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,19 +9,18 @@ from itertools import product
9
9
  from math import ceil
10
10
  from typing import Dict, List, Optional, Tuple, Union
11
11
 
12
- import matplotlib.pyplot as plt
13
12
  import numpy as np
14
13
  import numpy.typing as npt
14
+ import sympy as sp
15
15
  from ase import Atoms
16
16
  from ase.cell import Cell
17
17
  from ase.constraints import FixSymmetry
18
18
  from ase.geometry import get_distances, get_duplicate_atoms
19
- from matplotlib.backends.backend_pdf import PdfPages
19
+ from ase.neighborlist import natural_cutoffs, neighbor_list
20
20
  from pymatgen.core.operations import SymmOp
21
21
  from pymatgen.core.tensors import Tensor
22
- from scipy.stats import kstest
23
- from sklearn.decomposition import PCA
24
22
  from sympy import Matrix, cos, matrix2numpy, sin, sqrt, symbols
23
+ from sympy.tensor.array.expressions import ArrayContraction, ArrayTensorProduct
25
24
 
26
25
  logger = logging.getLogger(__name__)
27
26
  logging.basicConfig(filename="kim-tools.log", level=logging.INFO, force=True)
@@ -48,6 +47,7 @@ __all__ = [
48
47
  "change_of_basis_atoms",
49
48
  "get_possible_primitive_shifts",
50
49
  "get_primitive_genpos_ops",
50
+ "get_smallest_nn_dist",
51
51
  ]
52
52
 
53
53
  C_CENTERED_ORTHORHOMBIC_GROUPS = (20, 21, 35, 36, 37, 63, 64, 65, 66, 67, 68)
@@ -514,7 +514,9 @@ def get_change_of_basis_matrix_to_conventional_cell_from_formal_bravais_lattice(
514
514
  return np.round(change_of_basis_matrix)
515
515
 
516
516
 
517
- def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms:
517
+ def change_of_basis_atoms(
518
+ atoms: Atoms, change_of_basis: npt.ArrayLike, cutoff: Optional[float] = None
519
+ ) -> Atoms:
518
520
  """
519
521
  Perform an arbitrary basis change on an ``Atoms`` object, duplicating or cropping
520
522
  atoms as needed. A basic check is made that the determinant of ``change_of_basis``
@@ -522,7 +524,7 @@ def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms
522
524
  that ``change_of_basis`` is appropriate for the particuar crystal described by
523
525
  ``atoms``, which is up to the user.
524
526
 
525
- TODO: Incorporate :func:`kstest_reduced_distances` into this function
527
+ TODO: Incorporate period extension test into this function
526
528
 
527
529
  Args:
528
530
  atoms:
@@ -541,6 +543,9 @@ def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms
541
543
 
542
544
  Relationship between fractional coordinates in each basis:
543
545
  **x** = **P** **x**'
546
+ cutoff:
547
+ The cutoff to use for deleting duplicate atoms. If not specified,
548
+ the AFLOW tolerance of 0.01*(smallest NN distance) is used.
544
549
 
545
550
  Returns:
546
551
  The transformed ``Atoms`` object, containing the original number of
@@ -575,7 +580,9 @@ def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms
575
580
  new_atoms = atoms.repeat(repeat)
576
581
  new_atoms.set_cell(new_cell)
577
582
  new_atoms.wrap()
578
- get_duplicate_atoms(new_atoms, delete=True)
583
+ if cutoff is None:
584
+ cutoff = get_smallest_nn_dist(atoms) * 0.01
585
+ get_duplicate_atoms(new_atoms, cutoff=cutoff, delete=True)
579
586
 
580
587
  volume_change = np.linalg.det(change_of_basis)
581
588
  if not np.isclose(len(atoms) * volume_change, len(new_atoms)):
@@ -632,14 +639,16 @@ def transform_atoms(atoms: Atoms, op: Dict) -> Atoms:
632
639
  return atoms_transformed
633
640
 
634
641
 
635
- def reduce_and_avg(
636
- atoms: Atoms, repeat: Tuple[int, int, int]
637
- ) -> Tuple[Atoms, npt.ArrayLike]:
642
+ def reduce_and_avg(atoms: Atoms, repeat: Tuple[int, int, int]) -> Atoms:
638
643
  """
639
644
  TODO: Upgrade :func:`change_of_basis_atoms` to provide the distances
640
645
  array, obviating this function
641
646
 
642
- Function to reduce all atoms to the original unit cell position.
647
+ Function to reduce all atoms to the original unit cell position,
648
+ assuming the supercell is built from contiguous repeats of the unit cell
649
+ (i.e. atoms 0 to N-1 in the supercell are the original unit cell, atoms N to
650
+ 2*[N-1] are the original unit cell shifted by an integer multiple of
651
+ the lattice vectors, and so on)
643
652
 
644
653
  Args:
645
654
  atoms:
@@ -649,10 +658,13 @@ def reduce_and_avg(
649
658
  provided supercell
650
659
 
651
660
  Returns:
652
- * The reduced unit cell
653
- * An array of displacement vectors. First dimension: index of reference atom
654
- in reduced cell. Second dimension: index of atom in provided supercell.
655
- Third dimension: x, y, z
661
+ The reduced unit cell
662
+
663
+ Raises:
664
+ PeriodExtensionException:
665
+ If two atoms that should be identical by translational symmetry
666
+ are further than 0.01*(smallest NN distance) apart when
667
+ reduced to the unit cell
656
668
  """
657
669
  new_atoms = atoms.copy()
658
670
 
@@ -683,126 +695,267 @@ def reduce_and_avg(
683
695
  # Start from end of the atoms
684
696
  # because we will remove all atoms except the reference ones.
685
697
  for i in reversed(range(number_atoms)):
698
+ reference_atom_index = i % original_number_atoms
686
699
  if i >= original_number_atoms:
687
700
  # Get the distance to the reference atom in the original unit cell with the
688
701
  # minimum image convention.
689
702
  distance = new_atoms.get_distance(
690
- i % original_number_atoms, i, mic=True, vector=True
703
+ reference_atom_index, i, mic=True, vector=True
691
704
  )
692
705
  # Get the position that has the closest distance to
693
706
  # the reference atom in the original unit cell.
694
- position_i = positions[i % original_number_atoms] + distance
707
+ position_i = positions[reference_atom_index] + distance
695
708
  # Remove atom from atoms object.
696
709
  new_atoms.pop()
697
710
  else:
698
711
  # Atom was part of the original unit cell.
699
712
  position_i = positions[i]
700
713
  # Average
701
- avg_positions_in_prim_cell[i % original_number_atoms] += position_i / M
714
+ avg_positions_in_prim_cell[reference_atom_index] += position_i / M
702
715
  positions_in_prim_cell[i] = position_i
703
716
 
704
717
  new_atoms.set_positions(avg_positions_in_prim_cell)
705
718
 
706
- # Calculate the distances.
707
- distances = np.empty((original_number_atoms, M, 3))
708
- for i in range(number_atoms):
709
- dr, _ = get_distances(
710
- positions_in_prim_cell[i],
711
- avg_positions_in_prim_cell[i % original_number_atoms],
719
+ # Check that all atoms are within tolerance of their translational images
720
+ cutoff = get_smallest_nn_dist(new_atoms) * 0.01
721
+ logger.info(f"Cutoff for period extension test is {cutoff}")
722
+ for i in range(original_number_atoms):
723
+ positions_of_all_images_of_atom_i = [
724
+ positions_in_prim_cell[j * original_number_atoms + i] for j in range(M)
725
+ ]
726
+ _, r = get_distances(
727
+ positions_of_all_images_of_atom_i,
712
728
  cell=new_atoms.get_cell(),
713
729
  pbc=True,
714
730
  )
715
- # dr is a distance matrix, here we only have one distance
716
- assert dr.shape == (1, 1, 3)
717
- distances[i % original_number_atoms, i // original_number_atoms] = dr[0][0]
731
+ # Checking full MxM matrix, could probably speed up by
732
+ # checking upper triangle only. Could also save memory
733
+ # by looping over individual distances instead of
734
+ # checking the max of a giant matrix
735
+ assert r.shape == (M, M)
736
+ if r.max() > cutoff:
737
+ raise PeriodExtensionException(
738
+ f"At least one image of atom {i} is outside of tolerance"
739
+ )
740
+ return new_atoms
718
741
 
719
- return new_atoms, distances
742
+
743
+ def voigt_to_full_symb(voigt_input: sp.Array) -> sp.MutableDenseNDimArray:
744
+ """
745
+ Convert a 3-dimensional symbolic Voigt matrix to a full tensor. Order is
746
+ automatically detected. For now, only works with tensors that don't have special
747
+ scaling for the Voigt matrix (e.g. this doesn't work with the
748
+ compliance tensor)
749
+ """
750
+ order = sum(voigt_input.shape) // 3
751
+ this_voigt_map = Tensor.get_voigt_dict(order)
752
+ t = sp.MutableDenseNDimArray(np.zeros([3] * order))
753
+ for ind, v in this_voigt_map.items():
754
+ t[ind] = voigt_input[v]
755
+ return t
720
756
 
721
757
 
722
- def kstest_reduced_distances(
723
- reduced_distances: npt.ArrayLike,
724
- significance_level: float = 0.05,
725
- plot_filename: Optional[str] = None,
726
- number_bins: Optional[int] = None,
727
- ) -> None:
758
+ def full_to_voigt_symb(full: sp.Array) -> sp.MutableDenseNDimArray:
728
759
  """
729
- TODO: Incorporate this into :func:`change_of_basis_atoms`
760
+ Convert a 3-dimensional symbolic full tensor to a Voigt matrix. Order is
761
+ automatically detected. For now, only works with tensors that don't have special
762
+ scaling for the Voigt matrix (e.g. this doesn't work with the
763
+ compliance tensor). No error checking is done to see if the
764
+ full tensor has the required symmetries to be converted to Voigt.
765
+ """
766
+ order = len(full.shape)
767
+ vshape = tuple([3] * (order % 2) + [6] * (order // 2))
768
+ v_matrix = sp.MutableDenseNDimArray(np.zeros(vshape))
769
+ this_voigt_map = Tensor.get_voigt_dict(order)
770
+ for ind, v in this_voigt_map.items():
771
+ v_matrix[v] = full[ind]
772
+ return v_matrix
730
773
 
731
- Function to test whether the reduced atom positions are normally distributed
732
- around their average.
774
+
775
+ def rotate_tensor_symb(t: sp.Array, r: sp.Array) -> sp.Array:
776
+ """
777
+ Rotate a 3-dimensional symbolic Cartesian tensor by a rotation matrix.
733
778
 
734
779
  Args:
735
- reduced_distances:
736
- Distance array provided by :func:`reduce_and_avg`
737
- significance_level:
738
- Significance level for Kolmogorov-Smirnov
739
- plot_filename:
740
- number_bins:
741
- Number of bins for plot
780
+ t: The tensor to rotate
781
+ r:
782
+ The rotation matrix, or a precomputed tensor product of rotation matrices
783
+ with the correct rank
784
+ """
785
+ order = len(t.shape)
786
+ if r.shape == (3, 3):
787
+ r_tenprod = [sp.Array(r)] * order
788
+ elif r.shape == tuple([3] * 2 * order):
789
+ r_tenprod = [sp.Array(r)]
790
+ else:
791
+ raise RuntimeError(
792
+ "r must be a 3x3 rotation matrix or a tensor product of n 3x3 rotation "
793
+ f"matrices, where n is the rank of t. Instead got shape f{r.shape}"
794
+ )
795
+ args = r_tenprod + [t]
796
+ fullproduct = ArrayTensorProduct(*args)
797
+ for i in range(order):
798
+ current_order = len(fullproduct.shape)
799
+ # Count back from end: one component of tensor,
800
+ # plus two components for each rotation matrix.
801
+ # Then, step forward by 2*i + 1 to land on the second
802
+ # component of the correct rotation matrix.
803
+ # but, step forward by i more, because we've knocked out
804
+ # that many components of the tensor already
805
+ # (the knocked out components of the rotation matrices
806
+ # are lower than the current component we are summing)
807
+ rotation_component = current_order - order * 3 + 3 * i + 1
808
+ tensor_component = current_order - order + i # Count back from end
809
+ fullproduct = ArrayContraction(
810
+ fullproduct, (rotation_component, tensor_component)
811
+ )
812
+ return fullproduct.as_explicit()
742
813
 
743
- Raises:
744
- PeriodExtensionException:
745
- If a non-normal distribution is detected
814
+
815
+ def fit_voigt_tensor_to_cell_and_space_group_symb(
816
+ symb_voigt_inp: sp.Array,
817
+ cell: npt.ArrayLike,
818
+ sgnum: Union[int, str],
819
+ ):
746
820
  """
747
- assert len(reduced_distances.shape) == 3
748
- assert reduced_distances.shape[2] == 3
821
+ Given a Cartesian symbolic tensor in Voigt form, average it over all the operations
822
+ in the crystal's space group in order to remove violations of the material symmetry
823
+ due to numerical errors. Similar to
824
+ :meth:`pymatgen.core.tensors.Tensor.fit_to_structure`,
825
+ except the input in output are Voigt, and the symmetry operations are tabulated
826
+ instead of being detected on the fly from a structure.
749
827
 
750
- if plot_filename is not None:
751
- if number_bins is None:
752
- raise ValueError(
753
- "number_bins must be specified if plot_filename is specified"
754
- )
755
- if not plot_filename.endswith(".pdf"):
756
- raise ValueError(f"{plot_filename} is not a PDF file")
757
- with PdfPages(plot_filename) as pdf:
758
- for i in range(reduced_distances.shape[0]):
759
- fig, axs = plt.subplots(1, 3, figsize=(10.0, 4.0))
760
- for j in range(reduced_distances.shape[2]):
761
- axs[j].hist(reduced_distances[i, :, j], bins=number_bins)
762
- axs[j].set_xlabel(f"$x_{j}$")
763
- axs[0].set_ylabel("Counts")
764
- fig.suptitle(f"Atom {i}")
765
- pdf.savefig()
766
- else:
767
- if number_bins is not None:
768
- raise ValueError(
769
- "number_bins must not be specified if plot_filename is not specified"
770
- )
828
+ The provided tensor and cell must be in the standard primitive
829
+ setting and orientation w.r.t. Cartesian coordinates as defined in
830
+ https://doi.org/10.1016/j.commatsci.2017.01.017
771
831
 
772
- p_values = np.empty((reduced_distances.shape[0], reduced_distances.shape[2]))
773
- for i in range(reduced_distances.shape[0]):
774
- atom_distances = reduced_distances[i]
832
+ Args:
833
+ symb_voigt_inp:
834
+ Tensor in Voigt form as understood by
835
+ :meth:`pymatgen.core.tensors.Tensor.from_voigt`
836
+ cell:
837
+ The cell of the crystal, with each row being a cartesian vector
838
+ representing a lattice vector
839
+ sgnum:
840
+ Space group number
775
841
 
776
- # Perform PCA on the xyz distribution.
777
- pca = PCA(n_components=atom_distances.shape[1])
778
- pca_components = pca.fit_transform(atom_distances)
779
- assert (
780
- pca_components.shape == atom_distances.shape == reduced_distances.shape[1:]
781
- )
842
+ Returns:
843
+ Tensor symmetrized w.r.t. operations of the space group,
844
+ additionally the symmetrized error if `voigt_error`
845
+ is provided
846
+ """
847
+ t = voigt_to_full_symb(symb_voigt_inp)
848
+ order = len(t.shape)
782
849
 
783
- # Test each component with a KS test.
784
- for j in range(pca_components.shape[1]):
785
- component = pca_components[:, j]
786
- component_mean = np.mean(component)
787
- assert abs(component_mean) < 1.0e-7
788
- component_std = np.std(component)
789
- # Normalize component
790
- normalized_component = (component - component_mean) / component_std
791
- assert abs(np.mean(normalized_component)) < 1.0e-7
792
- assert abs(np.std(normalized_component) - 1.0) < 1.0e-7
793
- res = kstest(normalized_component, "norm")
794
- p_values[i, j] = res.pvalue
795
-
796
- if np.any(p_values <= significance_level):
797
- raise PeriodExtensionException(
798
- "Detected non-normal distribution of reduced atom positions around their "
799
- f"average (smallest p value {np.min(p_values)})."
800
- )
801
- else:
802
- print(
803
- "Detected normal distribution or reduced atom positions around their "
804
- f"average (smallest p value {np.min(p_values)})."
805
- )
850
+ # Precompute the average Q (x) Q (x) Q (x) Q for each
851
+ # Q in G, where (x) is tensor product. Better
852
+ # to do this with numpy, sympy is SLOW
853
+ r_tensprod_ave = np.zeros([3] * 2 * order, dtype=float)
854
+ space_group_ops = get_primitive_genpos_ops(sgnum)
855
+ for op in space_group_ops:
856
+ frac_rot = op["W"]
857
+ cart_rot = fractional_to_cartesian_itc_rotation_from_ase_cell(frac_rot, cell)
858
+ r_tensprod = 1
859
+ for _ in range(order):
860
+ # tensordot with axes=0 is tensor product
861
+ r_tensprod = np.tensordot(r_tensprod, cart_rot, axes=0)
862
+ r_tensprod_ave += r_tensprod
863
+ r_tensprod_ave /= len(space_group_ops)
864
+ t_symmetrized = rotate_tensor_symb(t, r_tensprod_ave)
865
+ return full_to_voigt_symb(t_symmetrized)
866
+
867
+
868
+ def fit_voigt_tensor_and_error_to_cell_and_space_group(
869
+ voigt_input: npt.ArrayLike,
870
+ voigt_error: npt.ArrayLike,
871
+ cell: npt.ArrayLike,
872
+ sgnum: Union[int, str],
873
+ symmetric: bool = False,
874
+ ) -> Tuple[npt.ArrayLike, npt.ArrayLike]:
875
+ """
876
+ Given a Cartesian Tensor and its errors in Voigt form, average them over
877
+ all the operations in the
878
+ crystal's space group in order to remove violations of the material symmetry due to
879
+ numerical errors. Similar to :meth:`pymatgen.core.tensors.Tensor.fit_to_structure`,
880
+ except the input in output are Voigt, and the symmetry operations are tabulated
881
+ instead of being detected on the fly from a structure.
882
+
883
+ Only use this function if you need the errors. If you do not,
884
+ use
885
+ :func:`fit_voigt_tensor_to_cell_and_space_group`, which is significantly faster.
886
+
887
+ The provided tensor and cell must be in the standard primitive
888
+ setting and orientation w.r.t. Cartesian coordinates as defined in
889
+ https://doi.org/10.1016/j.commatsci.2017.01.017
890
+
891
+ Args:
892
+ voigt_input:
893
+ Tensor in Voigt form as understood by
894
+ :meth:`pymatgen.core.tensors.Tensor.from_voigt`
895
+ voigt_error:
896
+ The error corresponding to voigt_input
897
+ cell:
898
+ The cell of the crystal, with each row being a cartesian vector
899
+ representing a lattice vector
900
+ sgnum:
901
+ Space group number
902
+ symmetric:
903
+ Whether the provided matrix is symmetric. Currently
904
+ only supported for 6x6 Voigt matrices
905
+
906
+ Returns:
907
+ Tensor symmetrized w.r.t. operations of the space group,
908
+ and its symmetrized error
909
+ """
910
+ # First, get the symmetrized tensor as a symbolic
911
+ voigt_shape = voigt_input.shape
912
+ symb_voigt_inp = sp.symarray("t", voigt_shape)
913
+ if symmetric:
914
+ if voigt_shape != (6, 6):
915
+ raise NotImplementedError(
916
+ "Symmetric input only supported for 6x6 Voigt matrices"
917
+ )
918
+ for i in range(5):
919
+ for j in range(i + 1, 6):
920
+ symb_voigt_inp[j, i] = symb_voigt_inp[i, j]
921
+
922
+ sym_voigt_out = fit_voigt_tensor_to_cell_and_space_group_symb(
923
+ symb_voigt_inp=symb_voigt_inp, cell=cell, sgnum=sgnum
924
+ )
925
+
926
+ # OK, got the symbolic voigt output. Set up machinery for
927
+ # substitution
928
+ voigt_ranges = [range(n) for n in voigt_shape]
929
+ # Convert to list so can be reused
930
+ voigt_ranges_product = list(product(*voigt_ranges))
931
+
932
+ # Substitute result. Symmetry not an issue, keys will get overwritten
933
+ sub_dict = {}
934
+ for symb, num in zip(symb_voigt_inp.flatten(), voigt_input.flatten()):
935
+ sub_dict[symb] = num
936
+
937
+ sub_dict_err = {}
938
+ for symb, num in zip(symb_voigt_inp.flatten(), voigt_error.flatten()):
939
+ sub_dict_err[symb] = num
940
+
941
+ voigt_out = np.zeros(voigt_shape, dtype=float)
942
+ voigt_err_out = np.zeros(voigt_shape, dtype=float)
943
+ for indices in voigt_ranges_product:
944
+ compon_expr = sym_voigt_out[indices]
945
+ voigt_out[indices] = compon_expr.subs(sub_dict)
946
+ # For the error, consider the current component (indicated by ``indices``)
947
+ # as a random variable that is a linear combination of all the components
948
+ # of voigt_inp. The variance of the
949
+ # current component will be the sum of a_i^2 var_i, where a_i is the
950
+ # coefficient of the ith component of voigt_inp
951
+ voigt_out_var_compon = 0
952
+ for symb in sub_dict_err:
953
+ inp_compon_coeff = float(compon_expr.coeff(symb))
954
+ inp_compon_var = sub_dict_err[symb] ** 2
955
+ voigt_out_var_compon += inp_compon_coeff**2 * inp_compon_var
956
+ voigt_err_out[indices] = voigt_out_var_compon**0.5
957
+
958
+ return voigt_out, voigt_err_out
806
959
 
807
960
 
808
961
  def fit_voigt_tensor_to_cell_and_space_group(
@@ -815,6 +968,10 @@ def fit_voigt_tensor_to_cell_and_space_group(
815
968
  except the input in output are Voigt, and the symmetry operations are tabulated
816
969
  instead of being detected on the fly from a structure.
817
970
 
971
+ If you need to symmetrize the errors as well, use
972
+ :func:`fit_voigt_tensor_and_error_to_cell_and_space_group`, which properly
973
+ handles errors, but is much slower.
974
+
818
975
  The provided tensor and cell must be in the standard primitive
819
976
  setting and orientation w.r.t. Cartesian coordinates as defined in
820
977
  https://doi.org/10.1016/j.commatsci.2017.01.017
@@ -849,6 +1006,23 @@ def fit_voigt_tensor_to_cell_and_space_group(
849
1006
  return t_symmetrized.voigt
850
1007
 
851
1008
 
1009
+ def get_smallest_nn_dist(atoms: Atoms) -> float:
1010
+ """
1011
+ Get the smallest NN distance in an Atoms object
1012
+ """
1013
+ nl_len = 0
1014
+ cov_mult = 1
1015
+ while nl_len == 0:
1016
+ logger.info(
1017
+ "Attempting to find NN distance by searching "
1018
+ f"within covalent radii times {cov_mult}"
1019
+ )
1020
+ nl = neighbor_list("d", atoms, natural_cutoffs(atoms, mult=cov_mult))
1021
+ nl_len = nl.size
1022
+ cov_mult += 1
1023
+ return nl.min()
1024
+
1025
+
852
1026
  class FixProvidedSymmetry(FixSymmetry):
853
1027
  """
854
1028
  A modification of :obj:`~ase.constraints.FixSymmetry` that takes
@@ -0,0 +1,105 @@
1
+ <?xml version='1.0' encoding='UTF-8'?>
2
+ <!-- This file was generated by dvisvgm 2.13.1 -->
3
+ <svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='350pt' height='350pt' viewBox='66.550958 63.999777 350 350'>
4
+ <g id='page1'>
5
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
6
+ <path d='M 10.1795 10.1795L 76.3702 76.3702L 142.561 142.561' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='1.50562'/>
7
+ </g>
8
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
9
+ <path d='M 76.3702 10.1795L 142.561 10.1795L 142.561 76.3702' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='1.50562'/>
10
+ </g>
11
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
12
+ <path d='M 208.752 208.752L 274.942 274.942L 341.133 341.133' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='1.50562'/>
13
+ </g>
14
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
15
+ <path d='M 20.1081 10.1795C 20.1081 4.69612 15.663 0.250938 10.1795 0.250937C 4.69612 0.250937 0.250937 4.69612 0.250937 10.1795C 0.250937 15.663 4.69612 20.1081 10.1795 20.1081C 15.663 20.1081 20.1081 15.663 20.1081 10.1795Z' fill='#000000'/>
16
+ </g>
17
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
18
+ <path d='M 20.1081 10.1795C 20.1081 4.69612 15.663 0.250938 10.1795 0.250937C 4.69612 0.250937 0.250937 4.69612 0.250937 10.1795C 0.250937 15.663 4.69612 20.1081 10.1795 20.1081C 15.663 20.1081 20.1081 15.663 20.1081 10.1795Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
19
+ </g>
20
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
21
+ <path d='M 86.2988 10.1795C 86.2988 4.69612 81.8536 0.250938 76.3702 0.250937C 70.8868 0.250937 66.4416 4.69612 66.4416 10.1795C 66.4416 15.663 70.8868 20.1081 76.3702 20.1081C 81.8536 20.1081 86.2988 15.663 86.2988 10.1795Z' fill='#000000'/>
22
+ </g>
23
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
24
+ <path d='M 86.2988 10.1795C 86.2988 4.69612 81.8536 0.250938 76.3702 0.250937C 70.8868 0.250937 66.4416 4.69612 66.4416 10.1795C 66.4416 15.663 70.8868 20.1081 76.3702 20.1081C 81.8536 20.1081 86.2988 15.663 86.2988 10.1795Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
25
+ </g>
26
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
27
+ <path d='M 152.49 10.1795C 152.49 4.69612 148.044 0.250938 142.561 0.250937C 137.077 0.250937 132.632 4.69612 132.632 10.1795C 132.632 15.663 137.077 20.1081 142.561 20.1081C 148.044 20.1081 152.49 15.663 152.49 10.1795Z' fill='#000000'/>
28
+ </g>
29
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
30
+ <path d='M 152.49 10.1795C 152.49 4.69612 148.044 0.250938 142.561 0.250937C 137.077 0.250937 132.632 4.69612 132.632 10.1795C 132.632 15.663 137.077 20.1081 142.561 20.1081C 148.044 20.1081 152.49 15.663 152.49 10.1795Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
31
+ </g>
32
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
33
+ <circle cx='208.752' cy='10.1795' fill='#000000' r='2.50937'/>
34
+ </g>
35
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
36
+ <circle cx='274.942' cy='10.1795' fill='#000000' r='2.50937'/>
37
+ </g>
38
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
39
+ <circle cx='341.133' cy='10.1795' fill='#000000' r='2.50937'/>
40
+ </g>
41
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
42
+ <path d='M 86.2988 76.3702C 86.2988 70.8868 81.8536 66.4416 76.3702 66.4416C 70.8868 66.4416 66.4416 70.8868 66.4416 76.3702C 66.4416 81.8536 70.8868 86.2988 76.3702 86.2988C 81.8536 86.2988 86.2988 81.8536 86.2988 76.3702Z' fill='#000000'/>
43
+ </g>
44
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
45
+ <path d='M 86.2988 76.3702C 86.2988 70.8868 81.8536 66.4416 76.3702 66.4416C 70.8868 66.4416 66.4416 70.8868 66.4416 76.3702C 66.4416 81.8536 70.8868 86.2988 76.3702 86.2988C 81.8536 86.2988 86.2988 81.8536 86.2988 76.3702Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
46
+ </g>
47
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
48
+ <path d='M 152.49 76.3702C 152.49 70.8868 148.044 66.4416 142.561 66.4416C 137.077 66.4416 132.632 70.8868 132.632 76.3702C 132.632 81.8536 137.077 86.2988 142.561 86.2988C 148.044 86.2988 152.49 81.8536 152.49 76.3702Z' fill='#000000'/>
49
+ </g>
50
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
51
+ <path d='M 152.49 76.3702C 152.49 70.8868 148.044 66.4416 142.561 66.4416C 137.077 66.4416 132.632 70.8868 132.632 76.3702C 132.632 81.8536 137.077 86.2988 142.561 86.2988C 148.044 86.2988 152.49 81.8536 152.49 76.3702Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
52
+ </g>
53
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
54
+ <circle cx='208.752' cy='76.3702' fill='#000000' r='2.50937'/>
55
+ </g>
56
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
57
+ <circle cx='274.942' cy='76.3702' fill='#000000' r='2.50937'/>
58
+ </g>
59
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
60
+ <circle cx='341.133' cy='76.3702' fill='#000000' r='2.50937'/>
61
+ </g>
62
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
63
+ <path d='M 152.49 142.561C 152.49 137.077 148.044 132.632 142.561 132.632C 137.077 132.632 132.632 137.077 132.632 142.561C 132.632 148.044 137.077 152.49 142.561 152.49C 148.044 152.49 152.49 148.044 152.49 142.561Z' fill='#000000'/>
64
+ </g>
65
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
66
+ <path d='M 152.49 142.561C 152.49 137.077 148.044 132.632 142.561 132.632C 137.077 132.632 132.632 137.077 132.632 142.561C 132.632 148.044 137.077 152.49 142.561 152.49C 148.044 152.49 152.49 148.044 152.49 142.561Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
67
+ </g>
68
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
69
+ <circle cx='208.752' cy='142.561' fill='#000000' r='2.50937'/>
70
+ </g>
71
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
72
+ <circle cx='274.942' cy='142.561' fill='#000000' r='2.50937'/>
73
+ </g>
74
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
75
+ <circle cx='341.133' cy='142.561' fill='#000000' r='2.50937'/>
76
+ </g>
77
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
78
+ <path d='M 218.68 208.752C 218.68 203.268 214.235 198.823 208.752 198.823C 203.268 198.823 198.823 203.268 198.823 208.752C 198.823 214.235 203.268 218.68 208.752 218.68C 214.235 218.68 218.68 214.235 218.68 208.752Z' fill='#000000'/>
79
+ </g>
80
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
81
+ <path d='M 218.68 208.752C 218.68 203.268 214.235 198.823 208.752 198.823C 203.268 198.823 198.823 203.268 198.823 208.752C 198.823 214.235 203.268 218.68 208.752 218.68C 214.235 218.68 218.68 214.235 218.68 208.752Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
82
+ </g>
83
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
84
+ <circle cx='274.942' cy='208.752' fill='#000000' r='2.50937'/>
85
+ </g>
86
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
87
+ <circle cx='341.133' cy='208.752' fill='#000000' r='2.50937'/>
88
+ </g>
89
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
90
+ <path d='M 284.871 274.942C 284.871 269.459 280.426 265.014 274.942 265.014C 269.459 265.014 265.014 269.459 265.014 274.942C 265.014 280.426 269.459 284.871 274.942 284.871C 280.426 284.871 284.871 280.426 284.871 274.942Z' fill='#000000'/>
91
+ </g>
92
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
93
+ <path d='M 284.871 274.942C 284.871 269.459 280.426 265.014 274.942 265.014C 269.459 265.014 265.014 269.459 265.014 274.942C 265.014 280.426 269.459 284.871 274.942 284.871C 280.426 284.871 284.871 280.426 284.871 274.942Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
94
+ </g>
95
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
96
+ <circle cx='341.133' cy='274.942' fill='#000000' r='2.50937'/>
97
+ </g>
98
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
99
+ <path d='M 351.062 341.133C 351.062 335.65 346.616 331.204 341.133 331.204C 335.65 331.204 331.204 335.65 331.204 341.133C 331.204 346.616 335.65 351.062 341.133 351.062C 346.616 351.062 351.062 346.616 351.062 341.133Z' fill='#000000'/>
100
+ </g>
101
+ <g transform='translate(66.551 63.9998)scale(.996264)'>
102
+ <path d='M 351.062 341.133C 351.062 335.65 346.616 331.204 341.133 331.204C 335.65 331.204 331.204 335.65 331.204 341.133C 331.204 346.616 335.65 351.062 341.133 351.062C 346.616 351.062 351.062 346.616 351.062 341.133Z' fill='none' stroke='#000000' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10.0375' stroke-width='0.501875'/>
103
+ </g>
104
+ </g>
105
+ </svg>