kim-tools 0.3.5__py3-none-any.whl → 0.3.7__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.
kim_tools/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.5"
1
+ __version__ = "0.3.7"
2
2
 
3
3
  from .aflow_util import *
4
4
  from .aflow_util import __all__ as aflow_all
@@ -392,6 +392,67 @@ def get_wyckoff_lists_from_prototype(prototype_label: str) -> List[str]:
392
392
  return expanded_wyckoff_letters
393
393
 
394
394
 
395
+ def get_atom_indices_for_each_wyckoff_orb(prototype_label: str) -> List[Dict]:
396
+ """
397
+ Get a list of dictionaries containing the atom indices of each Wyckoff
398
+ orbit.
399
+
400
+ Returns:
401
+ The information is in this format -- ``[{"letter":"a", "indices":[0,1]}, ... ]``
402
+ """
403
+ return_list = []
404
+ wyck_lists = get_wyckoff_lists_from_prototype(prototype_label)
405
+ sgnum = get_space_group_number_from_prototype(prototype_label)
406
+ range_start = 0
407
+ for letter in "".join(wyck_lists):
408
+ multiplicity = get_primitive_wyckoff_multiplicity(sgnum, letter)
409
+ range_end = range_start + multiplicity
410
+ return_list.append(
411
+ {"letter": letter, "indices": list(range(range_start, range_end))}
412
+ )
413
+ range_start = range_end
414
+ return return_list
415
+
416
+
417
+ def get_all_equivalent_labels(prototype_label: str) -> List[str]:
418
+ """
419
+ Return all possible permutations of the Wyckoff letters in a prototype
420
+ label under the operations of the affine normalizer.
421
+
422
+ NOTE: For now this function will not completely enumerate the possibilities
423
+ for triclinic and monoclinic space groups
424
+ """
425
+ sgnum = get_space_group_number_from_prototype(prototype_label)
426
+ prototype_label_split = prototype_label.split("_")
427
+ equivalent_labels = []
428
+ for wyck_pos_xform in get_wyck_pos_xform_under_normalizer(sgnum):
429
+ prototype_label_split_permuted = prototype_label_split[:3]
430
+ for wycksec in prototype_label_split[3:]:
431
+ # list of letters joined with their nums, e.g. ["a", "2i"]
432
+ wycksec_permuted_list = []
433
+ prev_lett_ind = -1
434
+ for i, num_or_lett in enumerate(wycksec):
435
+ if isalpha(num_or_lett):
436
+ if num_or_lett == "A":
437
+ # Wyckoff position A comes after z in sg 47
438
+ lett_index = ord("z") + 1 - ord("a")
439
+ else:
440
+ lett_index = ord(num_or_lett) - ord("a")
441
+ # The start position of the (optional) numbers +
442
+ # letter describing this position
443
+ this_pos_start_ind = prev_lett_ind + 1
444
+ permuted_lett_and_num = wycksec[this_pos_start_ind:i]
445
+ permuted_lett_and_num += wyck_pos_xform[lett_index]
446
+ wycksec_permuted_list.append(permuted_lett_and_num)
447
+ prev_lett_ind = i
448
+ wycksec_permuted_list_sorted = sorted(
449
+ wycksec_permuted_list, key=lambda x: x[-1]
450
+ )
451
+ prototype_label_split_permuted.append("".join(wycksec_permuted_list_sorted))
452
+ equivalent_labels.append("_".join(prototype_label_split_permuted))
453
+ return list(set(equivalent_labels))
454
+
455
+
395
456
  def prototype_labels_are_equivalent(
396
457
  prototype_label_1: str,
397
458
  prototype_label_2: str,
@@ -841,9 +902,8 @@ class AFLOW:
841
902
  "that the AFLOW executable was not found."
842
903
  )
843
904
  # I am fine with allowing prereleases
844
- aflow_ver_no_prerelease = Version.parse(ver_str)
845
- aflow_ver_no_prerelease.replace(prerelease=None)
846
- if aflow_ver_no_prerelease < Version.parse(REQUIRED_AFLOW):
905
+ aflow_ver = Version.parse(ver_str)
906
+ if aflow_ver.replace(prerelease=None) < Version.parse(REQUIRED_AFLOW):
847
907
  raise self.AFLOWNotFoundException(
848
908
  f"Your AFLOW version {ver_str} is less "
849
909
  f"than the required {REQUIRED_AFLOW}"
@@ -1580,6 +1640,8 @@ class AFLOW:
1580
1640
  self.get_library_prototype_label_and_shortname_from_atoms(atoms)
1581
1641
  )
1582
1642
 
1643
+ # NOTE: Because of below, this only works if the provided prototype label is
1644
+ # correctly alphabetized. Change this?
1583
1645
  if not prototype_labels_are_equivalent(
1584
1646
  prototype_label, prototype_label_detected
1585
1647
  ):
@@ -1642,7 +1704,7 @@ class AFLOW:
1642
1704
  )
1643
1705
 
1644
1706
  position_set_list = get_equivalent_atom_sets_from_prototype_and_atom_map(
1645
- atoms, prototype_label, atom_map, sort_atoms=True
1707
+ atoms, prototype_label_detected, atom_map, sort_atoms=True
1646
1708
  )
1647
1709
 
1648
1710
  # get equation sets
@@ -12,8 +12,10 @@ from typing import Dict, List, Optional, Tuple, Union
12
12
  import matplotlib.pyplot as plt
13
13
  import numpy as np
14
14
  import numpy.typing as npt
15
+ import sympy as sp
15
16
  from ase import Atoms
16
17
  from ase.cell import Cell
18
+ from ase.constraints import FixSymmetry
17
19
  from ase.geometry import get_distances, get_duplicate_atoms
18
20
  from matplotlib.backends.backend_pdf import PdfPages
19
21
  from pymatgen.core.operations import SymmOp
@@ -21,6 +23,7 @@ from pymatgen.core.tensors import Tensor
21
23
  from scipy.stats import kstest
22
24
  from sklearn.decomposition import PCA
23
25
  from sympy import Matrix, cos, matrix2numpy, sin, sqrt, symbols
26
+ from sympy.tensor.array.expressions import ArrayContraction, ArrayTensorProduct
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
  logging.basicConfig(filename="kim-tools.log", level=logging.INFO, force=True)
@@ -617,6 +620,20 @@ def get_primitive_genpos_ops(sgnum: Union[int, str]) -> List[Dict]:
617
620
  return np.asarray(json.load(f)[str(sgnum)])
618
621
 
619
622
 
623
+ def transform_atoms(atoms: Atoms, op: Dict) -> Atoms:
624
+ """
625
+ Transform atoms by an operation defined by a dictionary containing a matrix 'W' and
626
+ translation 'w' defined as fractional operations in the unit cell. 'W' should be
627
+ oriented to operate on column vectors
628
+ """
629
+ frac_pos_columns = atoms.get_scaled_positions().T
630
+ frac_pos_cols_xform = op["W"] @ frac_pos_columns + np.reshape(op["w"], (3, 1))
631
+ atoms_transformed = atoms.copy()
632
+ atoms_transformed.set_scaled_positions(frac_pos_cols_xform.T)
633
+ atoms_transformed.wrap()
634
+ return atoms_transformed
635
+
636
+
620
637
  def reduce_and_avg(
621
638
  atoms: Atoms, repeat: Tuple[int, int, int]
622
639
  ) -> Tuple[Atoms, npt.ArrayLike]:
@@ -790,6 +807,224 @@ def kstest_reduced_distances(
790
807
  )
791
808
 
792
809
 
810
+ def voigt_to_full_symb(voigt_input: sp.Array) -> sp.MutableDenseNDimArray:
811
+ """
812
+ Convert a 3-dimensional symbolic Voigt matrix to a full tensor. Order is
813
+ automatically detected. For now, only works with tensors that don't have special
814
+ scaling for the Voigt matrix (e.g. this doesn't work with the
815
+ compliance tensor)
816
+ """
817
+ order = sum(voigt_input.shape) // 3
818
+ this_voigt_map = Tensor.get_voigt_dict(order)
819
+ t = sp.MutableDenseNDimArray(np.zeros([3] * order))
820
+ for ind, v in this_voigt_map.items():
821
+ t[ind] = voigt_input[v]
822
+ return t
823
+
824
+
825
+ def full_to_voigt_symb(full: sp.Array) -> sp.MutableDenseNDimArray:
826
+ """
827
+ Convert a 3-dimensional symbolic full tensor to a Voigt matrix. Order is
828
+ automatically detected. For now, only works with tensors that don't have special
829
+ scaling for the Voigt matrix (e.g. this doesn't work with the
830
+ compliance tensor). No error checking is done to see if the
831
+ full tensor has the required symmetries to be converted to Voigt.
832
+ """
833
+ order = len(full.shape)
834
+ vshape = tuple([3] * (order % 2) + [6] * (order // 2))
835
+ v_matrix = sp.MutableDenseNDimArray(np.zeros(vshape))
836
+ this_voigt_map = Tensor.get_voigt_dict(order)
837
+ for ind, v in this_voigt_map.items():
838
+ v_matrix[v] = full[ind]
839
+ return v_matrix
840
+
841
+
842
+ def rotate_tensor_symb(t: sp.Array, r: sp.Array) -> sp.Array:
843
+ """
844
+ Rotate a 3-dimensional symbolic Cartesian tensor by a rotation matrix.
845
+
846
+ Args:
847
+ t: The tensor to rotate
848
+ r:
849
+ The rotation matrix, or a precomputed tensor product of rotation matrices
850
+ with the correct rank
851
+ """
852
+ order = len(t.shape)
853
+ if r.shape == (3, 3):
854
+ r_tenprod = [sp.Array(r)] * order
855
+ elif r.shape == tuple([3] * 2 * order):
856
+ r_tenprod = [sp.Array(r)]
857
+ else:
858
+ raise RuntimeError(
859
+ "r must be a 3x3 rotation matrix or a tensor product of n 3x3 rotation "
860
+ f"matrices, where n is the rank of t. Instead got shape f{r.shape}"
861
+ )
862
+ args = r_tenprod + [t]
863
+ fullproduct = ArrayTensorProduct(*args)
864
+ for i in range(order):
865
+ current_order = len(fullproduct.shape)
866
+ # Count back from end: one component of tensor,
867
+ # plus two components for each rotation matrix.
868
+ # Then, step forward by 2*i + 1 to land on the second
869
+ # component of the correct rotation matrix.
870
+ # but, step forward by i more, because we've knocked out
871
+ # that many components of the tensor already
872
+ # (the knocked out components of the rotation matrices
873
+ # are lower than the current component we are summing)
874
+ rotation_component = current_order - order * 3 + 3 * i + 1
875
+ tensor_component = current_order - order + i # Count back from end
876
+ fullproduct = ArrayContraction(
877
+ fullproduct, (rotation_component, tensor_component)
878
+ )
879
+ return fullproduct.as_explicit()
880
+
881
+
882
+ def fit_voigt_tensor_to_cell_and_space_group_symb(
883
+ symb_voigt_inp: sp.Array,
884
+ cell: npt.ArrayLike,
885
+ sgnum: Union[int, str],
886
+ ):
887
+ """
888
+ Given a Cartesian symbolic tensor in Voigt form, average it over all the operations
889
+ in the crystal's space group in order to remove violations of the material symmetry
890
+ due to numerical errors. Similar to
891
+ :meth:`pymatgen.core.tensors.Tensor.fit_to_structure`,
892
+ except the input in output are Voigt, and the symmetry operations are tabulated
893
+ instead of being detected on the fly from a structure.
894
+
895
+ The provided tensor and cell must be in the standard primitive
896
+ setting and orientation w.r.t. Cartesian coordinates as defined in
897
+ https://doi.org/10.1016/j.commatsci.2017.01.017
898
+
899
+ Args:
900
+ symb_voigt_inp:
901
+ Tensor in Voigt form as understood by
902
+ :meth:`pymatgen.core.tensors.Tensor.from_voigt`
903
+ cell:
904
+ The cell of the crystal, with each row being a cartesian vector
905
+ representing a lattice vector
906
+ sgnum:
907
+ Space group number
908
+
909
+ Returns:
910
+ Tensor symmetrized w.r.t. operations of the space group,
911
+ additionally the symmetrized error if `voigt_error`
912
+ is provided
913
+ """
914
+ t = voigt_to_full_symb(symb_voigt_inp)
915
+ order = len(t.shape)
916
+
917
+ # Precompute the average Q (x) Q (x) Q (x) Q for each
918
+ # Q in G, where (x) is tensor product. Better
919
+ # to do this with numpy, sympy is SLOW
920
+ r_tensprod_ave = np.zeros([3] * 2 * order, dtype=float)
921
+ space_group_ops = get_primitive_genpos_ops(sgnum)
922
+ for op in space_group_ops:
923
+ frac_rot = op["W"]
924
+ cart_rot = fractional_to_cartesian_itc_rotation_from_ase_cell(frac_rot, cell)
925
+ r_tensprod = 1
926
+ for _ in range(order):
927
+ # tensordot with axes=0 is tensor product
928
+ r_tensprod = np.tensordot(r_tensprod, cart_rot, axes=0)
929
+ r_tensprod_ave += r_tensprod
930
+ r_tensprod_ave /= len(space_group_ops)
931
+ t_symmetrized = rotate_tensor_symb(t, r_tensprod_ave)
932
+ return full_to_voigt_symb(t_symmetrized)
933
+
934
+
935
+ def fit_voigt_tensor_and_error_to_cell_and_space_group(
936
+ voigt_input: npt.ArrayLike,
937
+ voigt_error: npt.ArrayLike,
938
+ cell: npt.ArrayLike,
939
+ sgnum: Union[int, str],
940
+ symmetric: bool = False,
941
+ ) -> Tuple[npt.ArrayLike, npt.ArrayLike]:
942
+ """
943
+ Given a Cartesian Tensor and its errors in Voigt form, average them over
944
+ all the operations in the
945
+ crystal's space group in order to remove violations of the material symmetry due to
946
+ numerical errors. Similar to :meth:`pymatgen.core.tensors.Tensor.fit_to_structure`,
947
+ except the input in output are Voigt, and the symmetry operations are tabulated
948
+ instead of being detected on the fly from a structure.
949
+
950
+ Only use this function if you need the errors. If you do not,
951
+ use
952
+ :func:`fit_voigt_tensor_to_cell_and_space_group`, which is significantly faster.
953
+
954
+ The provided tensor and cell must be in the standard primitive
955
+ setting and orientation w.r.t. Cartesian coordinates as defined in
956
+ https://doi.org/10.1016/j.commatsci.2017.01.017
957
+
958
+ Args:
959
+ voigt_input:
960
+ Tensor in Voigt form as understood by
961
+ :meth:`pymatgen.core.tensors.Tensor.from_voigt`
962
+ voigt_error:
963
+ The error corresponding to voigt_input
964
+ cell:
965
+ The cell of the crystal, with each row being a cartesian vector
966
+ representing a lattice vector
967
+ sgnum:
968
+ Space group number
969
+ symmetric:
970
+ Whether the provided matrix is symmetric. Currently
971
+ only supported for 6x6 Voigt matrices
972
+
973
+ Returns:
974
+ Tensor symmetrized w.r.t. operations of the space group,
975
+ and its symmetrized error
976
+ """
977
+ # First, get the symmetrized tensor as a symbolic
978
+ voigt_shape = voigt_input.shape
979
+ symb_voigt_inp = sp.symarray("t", voigt_shape)
980
+ if symmetric:
981
+ if voigt_shape != (6, 6):
982
+ raise NotImplementedError(
983
+ "Symmetric input only supported for 6x6 Voigt matrices"
984
+ )
985
+ for i in range(5):
986
+ for j in range(i + 1, 6):
987
+ symb_voigt_inp[j, i] = symb_voigt_inp[i, j]
988
+
989
+ sym_voigt_out = fit_voigt_tensor_to_cell_and_space_group_symb(
990
+ symb_voigt_inp=symb_voigt_inp, cell=cell, sgnum=sgnum
991
+ )
992
+
993
+ # OK, got the symbolic voigt output. Set up machinery for
994
+ # substitution
995
+ voigt_ranges = [range(n) for n in voigt_shape]
996
+ # Convert to list so can be reused
997
+ voigt_ranges_product = list(product(*voigt_ranges))
998
+
999
+ # Substitute result. Symmetry not an issue, keys will get overwritten
1000
+ sub_dict = {}
1001
+ for symb, num in zip(symb_voigt_inp.flatten(), voigt_input.flatten()):
1002
+ sub_dict[symb] = num
1003
+
1004
+ sub_dict_err = {}
1005
+ for symb, num in zip(symb_voigt_inp.flatten(), voigt_error.flatten()):
1006
+ sub_dict_err[symb] = num
1007
+
1008
+ voigt_out = np.zeros(voigt_shape, dtype=float)
1009
+ voigt_err_out = np.zeros(voigt_shape, dtype=float)
1010
+ for indices in voigt_ranges_product:
1011
+ compon_expr = sym_voigt_out[indices]
1012
+ voigt_out[indices] = compon_expr.subs(sub_dict)
1013
+ # For the error, consider the current component (indicated by ``indices``)
1014
+ # as a random variable that is a linear combination of all the components
1015
+ # of voigt_inp. The variance of the
1016
+ # current component will be the sum of a_i^2 var_i, where a_i is the
1017
+ # coefficient of the ith component of voigt_inp
1018
+ voigt_out_var_compon = 0
1019
+ for symb in sub_dict_err:
1020
+ inp_compon_coeff = float(compon_expr.coeff(symb))
1021
+ inp_compon_var = sub_dict_err[symb] ** 2
1022
+ voigt_out_var_compon += inp_compon_coeff**2 * inp_compon_var
1023
+ voigt_err_out[indices] = voigt_out_var_compon**0.5
1024
+
1025
+ return voigt_out, voigt_err_out
1026
+
1027
+
793
1028
  def fit_voigt_tensor_to_cell_and_space_group(
794
1029
  voigt_input: npt.ArrayLike, cell: npt.ArrayLike, sgnum: Union[int, str]
795
1030
  ) -> npt.ArrayLike:
@@ -800,6 +1035,10 @@ def fit_voigt_tensor_to_cell_and_space_group(
800
1035
  except the input in output are Voigt, and the symmetry operations are tabulated
801
1036
  instead of being detected on the fly from a structure.
802
1037
 
1038
+ If you need to symmetrize the errors as well, use
1039
+ :func:`fit_voigt_tensor_and_error_to_cell_and_space_group`, which properly
1040
+ handles errors, but is much slower.
1041
+
803
1042
  The provided tensor and cell must be in the standard primitive
804
1043
  setting and orientation w.r.t. Cartesian coordinates as defined in
805
1044
  https://doi.org/10.1016/j.commatsci.2017.01.017
@@ -832,3 +1071,78 @@ def fit_voigt_tensor_to_cell_and_space_group(
832
1071
  t_symmetrized = sum(t_rotated_list) / len(t_rotated_list)
833
1072
 
834
1073
  return t_symmetrized.voigt
1074
+
1075
+
1076
+ class FixProvidedSymmetry(FixSymmetry):
1077
+ """
1078
+ A modification of :obj:`~ase.constraints.FixSymmetry` that takes
1079
+ a prescribed symmetry instead of analyzing the atoms object on the fly
1080
+ """
1081
+
1082
+ def __init__(
1083
+ self,
1084
+ atoms: Atoms,
1085
+ symmetry: Union[str, int, List[Dict]],
1086
+ adjust_positions=True,
1087
+ adjust_cell=True,
1088
+ ):
1089
+ """
1090
+ Args:
1091
+ symmetry:
1092
+ Either the space group number, or a list of operations
1093
+ as dictionaries with keys "W": (fractional rotation matrix),
1094
+ "w": (fractional translation). The space group number input
1095
+ will not work correctly unless this contraint is applied to
1096
+ a primitive unit cell as defined in
1097
+ http://doi.org/10.1016/j.commatsci.2017.01.017
1098
+ """
1099
+ self.atoms = atoms.copy()
1100
+ self.symmetry = symmetry
1101
+
1102
+ if isinstance(symmetry, str) or isinstance(symmetry, int):
1103
+ primitive_genpos_ops = get_primitive_genpos_ops(symmetry)
1104
+ else:
1105
+ try:
1106
+ for op in symmetry:
1107
+ assert np.asarray(op["W"]).shape == (3, 3)
1108
+ assert np.asarray(op["w"]).shape == (3,)
1109
+ primitive_genpos_ops = symmetry
1110
+ except Exception:
1111
+ raise RuntimeError("Incorrect input provided to FixProvidedSymmetry")
1112
+
1113
+ self.rotations = []
1114
+ self.translations = []
1115
+ for op in primitive_genpos_ops:
1116
+ self.rotations.append(np.asarray(op["W"]))
1117
+ self.translations.append(np.asarray(op["w"]))
1118
+ self.prep_symm_map()
1119
+
1120
+ self.do_adjust_positions = adjust_positions
1121
+ self.do_adjust_cell = adjust_cell
1122
+
1123
+ def prep_symm_map(self) -> None:
1124
+ """
1125
+ Prepare self.symm_map using provided symmetries
1126
+ """
1127
+ self.symm_map = []
1128
+ scaled_pos = self.atoms.get_scaled_positions()
1129
+ for rot, trans in zip(self.rotations, self.translations):
1130
+ this_op_map = [-1] * len(self.atoms)
1131
+ for i_at in range(len(self.atoms)):
1132
+ new_p = rot @ scaled_pos[i_at, :] + trans
1133
+ dp = scaled_pos - new_p
1134
+ dp -= np.round(dp)
1135
+ i_at_map = np.argmin(np.linalg.norm(dp, axis=1))
1136
+ this_op_map[i_at] = i_at_map
1137
+ self.symm_map.append(this_op_map)
1138
+
1139
+ def todict(self):
1140
+ return {
1141
+ "name": "FixProvidedSymmetry",
1142
+ "kwargs": {
1143
+ "atoms": self.atoms,
1144
+ "symmetry": self.symmetry,
1145
+ "adjust_positions": self.do_adjust_positions,
1146
+ "adjust_cell": self.do_adjust_cell,
1147
+ },
1148
+ }
@@ -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>