Skip to content

Commit 7ab5791

Browse files
authored
Merge pull request #69 from ccdc-opensource/surface_charge_script
Added Topology Surface Charge SCI-1404
2 parents 4929a81 + 89f1f8b commit 7ab5791

File tree

4 files changed

+99
-23
lines changed

4 files changed

+99
-23
lines changed

scripts/surface_charge/ReadMe.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Charges are currently calculated using the Gasteiger charge model. Further devel
1111

1212
Example Output:
1313

14-
![Example Output](assets/example_output.png)
14+
![Example Output](assets/example_output_hxacan28.png)
1515

1616
> **Note** - When comparing charges for non-CSD structures and structures from mol2 files the values might be different as the bonding might not be the same. When importing a mol2 file the bonding and charges may have to be calculated on the fly, whereas this information is assigned for CSD entries.
1717
-36.1 KB
Binary file not shown.
Loading

scripts/surface_charge/surface_charge_calculator.py

+98-22
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
# The following line states a licence feature that is required to show this script in Mercury and Hermes script menus.
99
# Created 18/08/2024 by Alex Moldovan
1010
# ccdc-licence-features not-applicable
11-
1211
import os
1312
import warnings
14-
from typing import List, Tuple
13+
from typing import List, Tuple, Dict
1514

1615
import numpy as np
1716
from ccdc.io import CrystalReader
@@ -29,30 +28,99 @@ def __init__(self, crystal, use_existing_charges: bool = False):
2928
self.crystal = crystal
3029
self.surface = None
3130
self._surface_charge = None
31+
self.surface_atom_charge = np.nan
32+
self.node_projected_charge = np.nan
33+
self.node_representative_charge = np.nan
34+
self.triangles_properties = dict()
35+
36+
@staticmethod
37+
def sum_atom_charge(atoms: List[object]) -> float:
38+
return np.round(np.sum([atom.partial_charge for atom in atoms]), 3)
39+
40+
def get_node_charge(self):
41+
self.node_charge_dictionary = {}
42+
node_list = list(self.surface.topology.nodes)
43+
for node, atoms in self.surface.surface_node_atom_contacts.items():
44+
node_index = node_list.index(node)
45+
total_node_charge = 0
46+
if len(atoms) > 0:
47+
total_node_charge = self.sum_atom_charge(atoms)
48+
self.node_charge_dictionary[node_index] = total_node_charge
49+
50+
def calculate_triangles_properties(self,
51+
tri_index: List[Tuple[int, int, int]]) -> None:
52+
surface_area = self.surface.descriptors.surface_area
53+
self.triangles_properties = {}
54+
triangle_areas = self.calculate_area_of_triangles(list(self.surface.topology.triangles))
55+
56+
for node_index, triangle_area in zip(tri_index, triangle_areas):
57+
average_triangle_charge = np.mean([self.node_charge_dictionary[i] for i in node_index])
58+
triangle_representation = triangle_area / surface_area
59+
projected_charge = 0
60+
if np.isclose(triangle_area, 0.0) is False:
61+
projected_charge = average_triangle_charge / triangle_area
62+
self.triangles_properties[tuple(node_index)] = {'Average Charge': average_triangle_charge,
63+
'Triangle Area': triangle_area,
64+
'Percentage Area': triangle_representation,
65+
'Node Representative Charge': average_triangle_charge * triangle_representation,
66+
'Node Projected Surface Charge': projected_charge}
67+
68+
def calculate_node_charges(self):
69+
tri_index = self.calculated_node_index_values(list(self.surface.topology.nodes),
70+
list(self.surface.topology.triangles))
71+
self.get_node_charge()
72+
self.calculate_triangles_properties(tri_index)
73+
self.representative_charge = np.sum(
74+
[triangle['Node Representative Charge'] for triangle in self.triangles_properties.values()])
75+
self.node_charges = np.sum([triangle['Average Charge'] for triangle in self.triangles_properties.values()])
76+
return self.representative_charge
77+
78+
@staticmethod
79+
def calculate_length(origin: np.ndarray, target: np.ndarray) -> float:
80+
"""Returns distance between target and origin"""
81+
if not isinstance(origin, np.ndarray) or not isinstance(target, np.ndarray):
82+
raise TypeError("Please supply numpy arrays for lengths.")
83+
return np.linalg.norm(target - origin)
84+
85+
@staticmethod
86+
def compute_simplex_area(simplex: np.ndarray) -> float:
87+
vec_1 = simplex[1] - simplex[0]
88+
vec_2 = simplex[2] - simplex[0]
89+
return np.linalg.norm(np.cross(vec_1, vec_2)) / 2
90+
91+
def calculate_area_of_triangles(self, triangles: List) -> List:
92+
""" Calculates area of individual triangles from node positions using Heron's formula"""
93+
return [self.compute_simplex_area(np.array(triangle)) for triangle in triangles]
94+
95+
@staticmethod
96+
def calculated_node_index_values(nodes: List, triangles: List) -> List:
97+
"""
98+
Calculate index of all triangles for nodes
99+
100+
:param nodes: list of nodes [x,y,z]
101+
:param triangles: list of triangles that contain nodes index values
102+
"""
103+
search_dictionary = {e: i for i, e in enumerate(nodes)}
104+
return [[search_dictionary[n] for n in tri] for tri in triangles]
32105

33106
def calculate_surface_charge(self, hkl: Tuple[int, int, int], offset: float):
34107
self.surface = Surface(self.crystal, miller_indices=hkl, offset=offset)
35108
if self.surface.slab.assign_partial_charges():
36-
self._surface_charge = np.round(np.sum([atom.partial_charge for atom in self.surface.surface_atoms]), 3)
109+
self.surface_atom_charge = self.sum_atom_charge(atoms=self.surface.surface_atoms)
110+
self.node_representative_charge = self.calculate_node_charges()
37111
return
38112
warnings.warn(f"Gasteiger charges could not be assigned to molecule: {self.crystal.identifier}",
39113
RuntimeWarning)
40-
self._surface_charge = np.nan
41-
42-
@property
43-
def surface_charge(self):
44-
if self._surface_charge is None:
45-
raise ValueError("Surface charge calculation not yet calculated, run calculate_surface_charge()")
46-
return self._surface_charge
47-
48-
@property
49-
def surface_charge_per_area(self):
50-
return self.surface_charge / self.surface.descriptors.projected_area
51114

52115

53116
class SurfaceChargeController:
54117
def __init__(self, structure: str, hkl_and_offsets: List[Tuple[int, int, int, float]],
55118
output_directory: str = None, use_existing_charges: bool = False):
119+
120+
self.surface_node_charges = []
121+
self.surface_areas = []
122+
self.surface_node_representative_charge = []
123+
self.surface_atom_charges = []
56124
self.surface_charges_per_area = []
57125
self.surface_charges = []
58126
self.projected_area = []
@@ -84,9 +152,12 @@ def calculate_surface_charge(self):
84152
for surface in self.surfaces:
85153
charges = SurfaceCharge(crystal=self.crystal, use_existing_charges=self.use_existing_charges)
86154
charges.calculate_surface_charge(hkl=surface[:3], offset=surface[3])
87-
self.surface_charges.append(charges.surface_charge)
155+
self.surface_atom_charges.append(charges.surface_atom_charge)
156+
self.surface_node_charges.append(charges.node_charges)
157+
self.surface_node_representative_charge.append(charges.node_representative_charge)
158+
88159
self.projected_area.append(charges.surface.descriptors.projected_area)
89-
self.surface_charges_per_area.append(charges.surface_charge_per_area)
160+
self.surface_areas.append(charges.surface.descriptors.surface_area)
90161

91162
def make_report(self):
92163
html_content = self.generate_html_table()
@@ -114,11 +185,13 @@ def generate_html_table(self):
114185
<h2>Calculation Results</h2>
115186
<table>
116187
<tr>
117-
<th>hkl</th>
188+
<th width="80px">hkl</th>
118189
<th>Offset</th>
119-
<th>Projected Area</th>
120-
<th>Surface Charge*</th>
121-
<th>Surface Charge per Projected Area</th>
190+
<th>P<sub>Area</sub>-Projected Area &#8491;<sup>2</sup></th>
191+
<th>S<sub>Area</sub>-Surface Area &#8491;<sup>2</sup></th>
192+
<th>Total Atom Surface Charge</th>
193+
<th>Total Atom Surface Charge/P<sub>A</sub></th>
194+
<th>Topological Surface Charge/ S<sub>Area</sub></th>
122195
</tr>
123196
"""
124197

@@ -130,15 +203,18 @@ def generate_html_table(self):
130203
<td>{hkl}</td>
131204
<td>{offset:.2f}</td>
132205
<td>{self.projected_area[i]:.3f}</td>
133-
<td>{self.surface_charges[i]:.3f}</td>
134-
<td>{self.surface_charges_per_area[i]:.4f}</td>
206+
<td>{self.surface_areas[i]:.3f}</td>
207+
<td>{self.surface_atom_charges[i]:.3f}</td>
208+
<td>{self.surface_atom_charges[i] / self.projected_area[i]:.4f}</td>
209+
<td>{self.surface_node_representative_charge[i]:.4f}</td>
135210
</tr>
136211
"""
137212

138213
# HTML Table Footer
139214
html += """
140215
</table>
141216
<p><i> *-Surface charge is based on gasteiger partial charges <a href="https://www.sciencedirect.com/science/article/pii/S0040403901949779?via%3Dihub">10.1016/S0040-4039(01)94977-9</a></i> </p>
217+
<p> Topological surface charge is defined as the average triangle charge on the surface multiplied by the % area contribution towards the total surface. </p>
142218
</body>
143219
</html>
144220
"""

0 commit comments

Comments
 (0)