Skip to content

Commit 7cffd87

Browse files
committedAug 20, 2024
Initial Commit of Surface Charge Script SCI-1315
1 parent 12688df commit 7cffd87

File tree

2 files changed

+414
-0
lines changed

2 files changed

+414
-0
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#
2+
# This script can be used for any purpose without limitation subject to the
3+
# conditions at http://www.ccdc.cam.ac.uk/Community/Pages/Licences/v2.aspx
4+
#
5+
# This permission notice and the following statement of attribution must be
6+
# included in all copies or substantial portions of this script.
7+
#
8+
# The following line states a licence feature that is required to show this script in Mercury and Hermes script menus.
9+
# Created 18/08/2024 by Alex Moldovan
10+
# ccdc-licence-features not-applicable
11+
12+
import os
13+
import warnings
14+
from typing import List, Tuple
15+
16+
import numpy as np
17+
from ccdc.io import CrystalReader
18+
from ccdc.particle import Surface
19+
from ccdc.utilities import HTMLReport
20+
21+
22+
class SurfaceCharge:
23+
def __init__(self, crystal, use_existing_charges: bool = False):
24+
if use_existing_charges:
25+
# if crystal.molecule.assign_partial_charges() is False:
26+
# warnings.warn(f"Gasteiger charges could not be assigned to molecule: {crystal.identifier}",
27+
# RuntimeWarning)
28+
raise NotImplementedError("Use existing charges instead. Current implementation only supports Gasteiger.")
29+
self.crystal = crystal
30+
self.surface = None
31+
self._surface_charge = None
32+
33+
def calculate_surface_charge(self, hkl: Tuple[int, int, int], offset: float):
34+
self.surface = Surface(self.crystal, miller_indices=hkl, offset=offset)
35+
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)
37+
return
38+
warnings.warn(f"Gasteiger charges could not be assigned to molecule: {self.crystal.identifier}",
39+
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
51+
52+
53+
class SurfaceChargeController:
54+
def __init__(self, structure: str, hkl_and_offsets: List[Tuple[int, int, int, float]],
55+
output_directory: str = None, use_existing_charges: bool = False):
56+
self.surface_charges_per_area = []
57+
self.surface_charges = []
58+
self.projected_area = []
59+
self.crystal = None
60+
if output_directory is None:
61+
output_directory = os.getcwd()
62+
self.output_directory = output_directory
63+
self.structure = structure
64+
self._surfaces = None
65+
self.get_structure()
66+
self.identifier = self.crystal.identifier
67+
self._surfaces = hkl_and_offsets
68+
self.use_existing_charges = use_existing_charges
69+
70+
def get_structure(self):
71+
if "." not in self.structure:
72+
self.crystal = CrystalReader('CSD').crystal(self.structure)
73+
elif ".mol2" in self.structure:
74+
self.crystal = CrystalReader(self.structure)[0]
75+
else:
76+
raise IOError(" \n ERROR : Please supply ref code mol2")
77+
78+
@property
79+
def surfaces(self):
80+
if self._surfaces:
81+
return self._surfaces
82+
83+
def calculate_surface_charge(self):
84+
for surface in self.surfaces:
85+
charges = SurfaceCharge(crystal=self.crystal, use_existing_charges=self.use_existing_charges)
86+
charges.calculate_surface_charge(hkl=surface[:3], offset=surface[3])
87+
self.surface_charges.append(charges.surface_charge)
88+
self.projected_area.append(charges.surface.descriptors.projected_area)
89+
self.surface_charges_per_area.append(charges.surface_charge_per_area)
90+
91+
def make_report(self):
92+
html_content = self.generate_html_table()
93+
output_file = os.path.join(self.output_directory, self.identifier + "_surface_charge.html")
94+
with HTMLReport(file_name=output_file,
95+
ccdc_header=True, append=False, embed_images=False,
96+
report_title=f'Surface Charge Calculations - {self.identifier}') as self.report:
97+
self.report.write(html_content)
98+
99+
print(f"Results saved to {output_file}")
100+
101+
def generate_html_table(self):
102+
# HTML Table Header
103+
html = """
104+
<html>
105+
<head>
106+
<title>Calculation Results</title>
107+
<style>
108+
table { width: 100%; border-collapse: collapse; }
109+
th, td { border: 1px solid black; padding: 8px; text-align: center; }
110+
th { background-color: #f2f2f2; }
111+
</style>
112+
</head>
113+
<body>
114+
<h2>Calculation Results</h2>
115+
<table>
116+
<tr>
117+
<th>hkl</th>
118+
<th>Offset</th>
119+
<th>Projected Area</th>
120+
<th>Surface Charge</th>
121+
<th>Surface Charge per Projected Area</th>
122+
</tr>
123+
"""
124+
125+
# HTML Table Rows
126+
for i, (h, k, l, offset) in enumerate(self.surfaces):
127+
hkl = "{" + f"{h}, {k}, {l}" + "}"
128+
html += f"""
129+
<tr>
130+
<td>{hkl}</td>
131+
<td>{offset:.2f}</td>
132+
<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>
135+
</tr>
136+
"""
137+
138+
# HTML Table Footer
139+
html += """
140+
</table>
141+
</body>
142+
</html>
143+
"""
144+
return html
+270
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#
2+
# This script can be used for any purpose without limitation subject to the
3+
# conditions at http://www.ccdc.cam.ac.uk/Community/Pages/Licences/v2.aspx
4+
#
5+
# This permission notice and the following statement of attribution must be
6+
# included in all copies or substantial portions of this script.
7+
#
8+
# The following line states a licence feature that is required to show this script in Mercury and Hermes script menus.
9+
# Created 18/08/2024 by Alex Moldovan (https://orcid.org/0000-0003-2776-3879)
10+
11+
12+
import os
13+
import sys
14+
import tkinter as tk
15+
from tkinter import ttk, messagebox, filedialog
16+
17+
from ccdc.utilities import ApplicationInterface
18+
19+
from _surface_charge_calculator import SurfaceChargeController
20+
21+
22+
class SurfaceChargeGUI:
23+
def __init__(self, initial_file_path=None):
24+
self.root = tk.Tk()
25+
self.root.title("Surface Charge Calculator")
26+
try:
27+
photo = tk.PhotoImage(file=os.path.join(os.path.dirname(__file__), 'assets/csd-python-api-logo.png'))
28+
self.root.wm_iconphoto(False, photo)
29+
except FileNotFoundError:
30+
print("Could not find icon file for app.")
31+
except Exception as e:
32+
print("Unable to load icon")
33+
print(e) # This doesn't seem to work with X11 port forwarding 🤷‍♀️
34+
# Disable window resizing
35+
self.root.resizable(False, False)
36+
37+
self.initial_file_path = initial_file_path
38+
self.create_string_file_inputs()
39+
self.create_input_fields()
40+
self.create_buttons()
41+
self.create_treeview()
42+
self.create_directory_selection()
43+
self.configure_grid() # Ensure grid configuration
44+
if self.initial_file_path:
45+
self.handle_initial_file_path(self.initial_file_path)
46+
47+
def handle_initial_file_path(self, file_path):
48+
"""Handles the initial file path by disabling the input fields and setting the file path."""
49+
self.file_var.set(file_path) # Set the provided file path
50+
self.string_var.set("") # Clear the string input
51+
52+
# Disable the input fields
53+
self.string_entry.config(state='disabled')
54+
self.file_entry.config(state='readonly')
55+
self.browse_button.config(state='disabled')
56+
57+
def configure_grid(self):
58+
self.root.grid_rowconfigure(8, weight=1)
59+
self.root.grid_rowconfigure(9, weight=0)
60+
self.root.grid_rowconfigure(10, weight=0)
61+
62+
self.root.grid_columnconfigure(0, weight=1)
63+
self.root.grid_columnconfigure(1, weight=1)
64+
self.root.grid_columnconfigure(2, weight=1)
65+
self.root.grid_columnconfigure(3, weight=1)
66+
self.root.grid_columnconfigure(4, weight=1)
67+
self.root.grid_columnconfigure(5, weight=1)
68+
self.root.grid_columnconfigure(6, weight=1)
69+
self.root.grid_columnconfigure(7, weight=1)
70+
71+
def create_string_file_inputs(self):
72+
tk.Label(self.root, text="Structure").grid(row=0, column=0, columnspan=2, sticky='w')
73+
74+
tk.Label(self.root, text="Refcode:").grid(row=1, column=0, padx=5, pady=5, sticky='e')
75+
self.string_var = tk.StringVar()
76+
self.string_entry = tk.Entry(self.root, textvariable=self.string_var, validate="key",
77+
validatecommand=(self.root.register(self.on_string_input), "%P"))
78+
self.string_entry.grid(row=1, column=1, padx=5, pady=5, columnspan=2, sticky='ew')
79+
80+
tk.Label(self.root, text="Select File:").grid(row=2, column=0, padx=5, pady=5, sticky='e')
81+
self.file_var = tk.StringVar()
82+
self.file_entry = tk.Entry(self.root, textvariable=self.file_var, state='readonly')
83+
self.file_entry.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky='ew')
84+
self.browse_button = tk.Button(self.root, text="Browse", command=self.browse_file)
85+
self.browse_button.grid(row=2, column=3, padx=5, pady=5, sticky='ew')
86+
87+
def on_string_input(self, input_value):
88+
if input_value.strip():
89+
self.browse_button.config(state='disabled')
90+
else:
91+
self.browse_button.config(state='normal')
92+
return True
93+
94+
def create_input_fields(self):
95+
tk.Label(self.root, text="Select hkl and offset").grid(row=3, column=0, columnspan=2, sticky='w')
96+
97+
input_frame = tk.Frame(self.root)
98+
input_frame.grid(row=4, column=0, columnspan=8, padx=5, pady=5, sticky='ew')
99+
100+
input_frame.grid_columnconfigure(0, weight=1)
101+
input_frame.grid_columnconfigure(1, weight=1)
102+
input_frame.grid_columnconfigure(2, weight=1)
103+
input_frame.grid_columnconfigure(3, weight=1)
104+
input_frame.grid_columnconfigure(4, weight=1)
105+
input_frame.grid_columnconfigure(5, weight=1)
106+
input_frame.grid_columnconfigure(6, weight=1)
107+
input_frame.grid_columnconfigure(7, weight=1)
108+
109+
tk.Label(input_frame, text="h:").grid(row=0, column=0, padx=2, pady=5, sticky='e')
110+
tk.Label(input_frame, text="k:").grid(row=0, column=2, padx=2, pady=5, sticky='e')
111+
tk.Label(input_frame, text="l:").grid(row=0, column=4, padx=2, pady=5, sticky='e')
112+
tk.Label(input_frame, text="offset:").grid(row=0, column=6, padx=2, pady=5, sticky='e')
113+
114+
self.h_var = tk.IntVar()
115+
self.spin_h = tk.Spinbox(input_frame, from_=-9, to=9, width=2, textvariable=self.h_var)
116+
self.spin_h.grid(row=0, column=1, padx=2, pady=5, sticky='ew')
117+
118+
self.k_var = tk.IntVar()
119+
self.spin_k = tk.Spinbox(input_frame, from_=-9, to=9, width=2, textvariable=self.k_var)
120+
self.spin_k.grid(row=0, column=3, padx=2, pady=5, sticky='ew')
121+
122+
self.l_var = tk.IntVar()
123+
self.spin_z = tk.Spinbox(input_frame, from_=-9, to=9, width=2, textvariable=self.l_var)
124+
self.spin_z.grid(row=0, column=5, padx=2, pady=5, sticky='ew')
125+
126+
self.offset_var = tk.DoubleVar()
127+
self.entry_offset = tk.Entry(input_frame, width=10, textvariable=self.offset_var)
128+
self.entry_offset.grid(row=0, column=7, padx=2, pady=5, sticky='ew')
129+
130+
def create_buttons(self):
131+
self.add_button = tk.Button(self.root, text="Add Surface", command=self.add_combination)
132+
self.add_button.grid(row=5, column=0, columnspan=2, pady=10, sticky='ew')
133+
134+
self.delete_button = tk.Button(self.root, text="Delete Selected", command=self.delete_combination)
135+
self.delete_button.grid(row=5, column=2, pady=5, sticky='ew')
136+
137+
self.reset_button = tk.Button(self.root, text="Reset Fields", command=self.reset_fields)
138+
self.reset_button.grid(row=5, column=3, pady=5, sticky='ew')
139+
140+
self.create_directory_selection()
141+
142+
def create_directory_selection(self):
143+
tk.Label(self.root, text="Output Directory:").grid(row=9, column=0, padx=5, pady=5, sticky='e')
144+
145+
self.dir_var = tk.StringVar(value=os.getcwd()) # Default to current working directory
146+
self.dir_entry = tk.Entry(self.root, textvariable=self.dir_var, state='readonly', width=50)
147+
self.dir_entry.grid(row=9, column=1, padx=5, pady=5, columnspan=3, sticky='ew')
148+
149+
self.browse_dir_button = tk.Button(self.root, text="Browse", command=self.select_directory)
150+
self.browse_dir_button.grid(row=9, column=4, padx=5, pady=5, sticky='ew')
151+
152+
self.calculate_button = tk.Button(self.root, text="Calculate", command=self.calculate)
153+
self.calculate_button.grid(row=10, column=0, columnspan=5, pady=10, sticky='ew')
154+
155+
def select_directory(self):
156+
selected_dir = filedialog.askdirectory(initialdir=self.dir_var.get(), title="Select Output Directory")
157+
if selected_dir:
158+
self.dir_var.set(selected_dir)
159+
160+
def create_treeview(self):
161+
162+
tk.Label(self.root, text="Current Selections").grid(row=7, column=0, padx=5, pady=5, columnspan=8,
163+
sticky='w')
164+
self.combination_tree = ttk.Treeview(self.root, columns=("h", "k", "l", "Offset"), show='headings')
165+
self.combination_tree.grid(row=8, column=0, columnspan=8, padx=10, pady=10, sticky='nsew')
166+
167+
self.combination_tree.heading("h", text="h")
168+
self.combination_tree.heading("k", text="k")
169+
self.combination_tree.heading("l", text="l")
170+
self.combination_tree.heading("Offset", text="Offset")
171+
172+
self.combination_tree.column("h", width=50, anchor=tk.CENTER)
173+
self.combination_tree.column("k", width=50, anchor=tk.CENTER)
174+
self.combination_tree.column("l", width=50, anchor=tk.CENTER)
175+
self.combination_tree.column("Offset", width=100, anchor=tk.CENTER)
176+
177+
def browse_file(self):
178+
file_path = filedialog.askopenfilename(filetypes=[("mol2 files", "*.mol2")])
179+
if file_path:
180+
self.file_var.set(file_path)
181+
182+
def add_combination(self):
183+
try:
184+
h = self.h_var.get()
185+
k = self.k_var.get()
186+
l = self.l_var.get()
187+
if (h, k, l) == (0, 0, 0):
188+
messagebox.showerror("Invalid input", "Please enter valid integers for h, k, l and a float for offset.")
189+
return
190+
offset = self.offset_var.get()
191+
combination = (h, k, l, offset)
192+
if not self.is_duplicate(combination):
193+
self.combination_tree.insert('', tk.END, values=combination)
194+
else:
195+
messagebox.showwarning("Duplicate Entry", "This hkl and offset already exists.")
196+
except tk.TclError:
197+
messagebox.showerror("Invalid input", "Please enter valid integers for h, k, l and a float for offset.")
198+
199+
def is_duplicate(self, combination):
200+
combination_converted = tuple((str(i) for i in combination))
201+
for row_id in self.combination_tree.get_children():
202+
row_values = self.combination_tree.item(row_id, 'values')
203+
if tuple(row_values) == combination_converted:
204+
return True
205+
return False
206+
207+
def delete_combination(self):
208+
selected_item = self.combination_tree.selection()
209+
if selected_item:
210+
self.combination_tree.delete(selected_item)
211+
else:
212+
messagebox.showwarning("No selection", "Please select a surface to delete.")
213+
214+
def reset_fields(self):
215+
self.h_var.set(0)
216+
self.k_var.set(0)
217+
self.l_var.set(0)
218+
self.offset_var.set(0.0)
219+
self.string_var.set("")
220+
self.file_var.set("")
221+
self.browse_button.config(state='normal')
222+
223+
def calculate(self):
224+
string_input = self.string_var.get().strip()
225+
file_input = self.file_var.get().strip()
226+
if not (string_input or file_input):
227+
tk.messagebox.showerror("Input Error", "Please provide a refcode or select a file.")
228+
return
229+
230+
if not self.combination_tree.get_children():
231+
tk.messagebox.showerror("Selection Error", "There must be at least one surface in the list.")
232+
return
233+
234+
items = self.combination_tree.get_children()
235+
data = []
236+
for item in items:
237+
values = self.combination_tree.item(item, 'values')
238+
try:
239+
h = int(values[0])
240+
k = int(values[1])
241+
l = int(values[2])
242+
offset = float(values[3])
243+
data.append((h, k, l, offset))
244+
except ValueError as e:
245+
print(f"Error converting data: {e}")
246+
continue
247+
if string_input:
248+
input_string = string_input # Use string input if available
249+
elif file_input:
250+
input_string = file_input
251+
252+
output_dir = self.dir_var.get()
253+
254+
surface_charge_controller = SurfaceChargeController(structure=input_string, output_directory=output_dir,
255+
hkl_and_offsets=data)
256+
surface_charge_controller.calculate_surface_charge()
257+
surface_charge_controller.make_report()
258+
self.root.destroy()
259+
260+
261+
if __name__ == "__main__":
262+
if len(sys.argv) > 3 and sys.argv[3].endswith(".m2a"):
263+
mercury = ApplicationInterface()
264+
run_from_mercury = True
265+
input_structure = mercury.input_mol2_file
266+
app = SurfaceChargeGUI(initial_file_path=input_structure)
267+
app.root.mainloop()
268+
else:
269+
app = SurfaceChargeGUI()
270+
app.root.mainloop()

0 commit comments

Comments
 (0)
Please sign in to comment.