Skip to content

Commit 8bfe7f6

Browse files
authored
Merge pull request #67 from ccdc-opensource/surface_charge_script
Surface Charge Script
2 parents 12688df + 51b4344 commit 8bfe7f6

9 files changed

+469
-0
lines changed

scripts/ReadMe.md

+4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ This folder contains scripts submitted by users or CCDC scientists for anyone to
4444

4545
- Calculates the simulated BFDH particle rugosity weighted by facet area.
4646

47+
## Surface Charge
48+
49+
- Calculates the surface charge for a given structure and surface terminations. Runs both from CMD and Mercury.
50+
4751
## Tips
4852

4953
A section for top tips in using the repository and GitHub.

scripts/surface_charge/ReadMe.md

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Surface Charge Calculator
2+
3+
## Summary
4+
5+
This tool returns the total surface charges for a given structure and list of supplied hkl indices and offsets.
6+
The script provides a GUI that can be used from Mercury or from the command line.
7+
8+
The output is an HTML file with a table for all the selected surfaces and their associated charges, projected surface areas, and normalised surface charges (surface charge per projected area).
9+
10+
Charges are currently calculated using the Gasteiger charge model. Further development could be made to use user derived charges. Please let us know if that is of interest: [support@ccdc.cam.ac.uk](support@ccdc.cam.ac.uk).
11+
12+
Example Output:
13+
14+
![Example Output](assets/example_output.png)
15+
16+
> **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.
17+
18+
## Requirements
19+
20+
- Requires a minimum of CSD 2022.2
21+
22+
## Licensing Requirements
23+
24+
- CSD-Particle Licence
25+
26+
## Instructions for use
27+
28+
- To Run from command line:
29+
30+
```commandline
31+
# With an activated environment
32+
> python surface_charge.py
33+
```
34+
35+
- To run from mercury:
36+
Add the folder containing the script to your Python API menu. Mercury -> CSD Python API-> Options -> Add Location. Then select the `surface_charge.py` script from the drop down menu
37+
![Adding_Locations](assets/adding_location.png)
38+
![Selecting Scripts](assets/selecting_script.png)
39+
40+
Running from either the command line or Mercury will show the same interface allowing you to select a refcode from the CSD or input a mol2 file directly.
41+
42+
Example Input:
43+
44+
![Example Input](assets/example_input.png)
45+
46+
## Author
47+
48+
Alex Moldovan (2024)
49+
50+
> For feedback or to report any issues please contact [support@ccdc.cam.ac.uk](mailto:support@ccdc.cam.ac.uk)
Loading
Loading
12.5 KB
Loading
36.1 KB
Loading
Loading
+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)