pystarc 1.1.0__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.
Files changed (67) hide show
  1. pystarc/__init__.py +9 -0
  2. pystarc/analysis/__init__.py +0 -0
  3. pystarc/analysis/convergence.py +182 -0
  4. pystarc/aux/__init__.py +0 -0
  5. pystarc/aux/aux_tools.py +151 -0
  6. pystarc/cli/__init__.py +0 -0
  7. pystarc/cli/main.py +151 -0
  8. pystarc/forces/__init__.py +0 -0
  9. pystarc/forces/electrostatic/__init__.py +0 -0
  10. pystarc/forces/electrostatic/grid_force.py +327 -0
  11. pystarc/forces/engine.py +680 -0
  12. pystarc/forces/gpu_batch_engine.py +657 -0
  13. pystarc/forces/lj.py +228 -0
  14. pystarc/forces/multipole.py +184 -0
  15. pystarc/forces/multipole_farfield.py +225 -0
  16. pystarc/global_defs/__init__.py +0 -0
  17. pystarc/global_defs/constants.py +85 -0
  18. pystarc/hydrodynamics/__init__.py +0 -0
  19. pystarc/hydrodynamics/mc_hydro_radius.py +266 -0
  20. pystarc/hydrodynamics/rotne_prager.py +215 -0
  21. pystarc/lib/__init__.py +0 -0
  22. pystarc/lib/numerical.py +145 -0
  23. pystarc/molsystem/__init__.py +0 -0
  24. pystarc/molsystem/system_state.py +97 -0
  25. pystarc/motion/__init__.py +0 -0
  26. pystarc/motion/adaptive_time_step.py +170 -0
  27. pystarc/motion/do_bd_step.py +195 -0
  28. pystarc/multi_GPU/__init__.py +0 -0
  29. pystarc/multi_GPU/combine_data.py +368 -0
  30. pystarc/multi_GPU/multi_GPU_runs.py +100 -0
  31. pystarc/pathways/__init__.py +0 -0
  32. pystarc/pathways/reaction_interface.py +100 -0
  33. pystarc/pipeline/__init__.py +0 -0
  34. pystarc/pipeline/extract.py +98 -0
  35. pystarc/pipeline/geometry.py +448 -0
  36. pystarc/pipeline/gho_injection.py +341 -0
  37. pystarc/pipeline/input_parser.py +334 -0
  38. pystarc/pipeline/make_pqr.py +217 -0
  39. pystarc/pipeline/output_writer.py +334 -0
  40. pystarc/pipeline/parameterize.py +104 -0
  41. pystarc/pipeline/pipeline.py +399 -0
  42. pystarc/pipeline/prepare_bd_surface.py +783 -0
  43. pystarc/pipeline/run_apbs.py +450 -0
  44. pystarc/simulation/__init__.py +0 -0
  45. pystarc/simulation/coffdrop_chain.py +645 -0
  46. pystarc/simulation/coffdrop_params.py +538 -0
  47. pystarc/simulation/diffusional_rotation.py +991 -0
  48. pystarc/simulation/gpu_batch_simulator.py +1377 -0
  49. pystarc/simulation/nam_simulator.py +649 -0
  50. pystarc/simulation/outer_propagator.py +401 -0
  51. pystarc/simulation/parallel.py +464 -0
  52. pystarc/simulation/step_near_surface.py +130 -0
  53. pystarc/simulation/we_simulator.py +466 -0
  54. pystarc/simulation/wiener.py +145 -0
  55. pystarc/structures/__init__.py +0 -0
  56. pystarc/structures/molecules.py +236 -0
  57. pystarc/structures/pqr_io.py +64 -0
  58. pystarc/transforms/__init__.py +0 -0
  59. pystarc/transforms/quaternion.py +187 -0
  60. pystarc/xml_io/__init__.py +0 -0
  61. pystarc/xml_io/simulation_io.py +130 -0
  62. pystarc-1.1.0.dist-info/METADATA +104 -0
  63. pystarc-1.1.0.dist-info/RECORD +67 -0
  64. pystarc-1.1.0.dist-info/WHEEL +5 -0
  65. pystarc-1.1.0.dist-info/entry_points.txt +5 -0
  66. pystarc-1.1.0.dist-info/licenses/LICENSE +21 -0
  67. pystarc-1.1.0.dist-info/top_level.txt +1 -0
pystarc/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ PySTARC - Python Simulation Toolkit for Association Rate Constants
3
+ """
4
+
5
+ __version__ = "1.1.0"
6
+ __author__ = "Anupam Anand Ojha"
7
+ __license__ = "MIT"
8
+
9
+ from pystarc.global_defs.constants import *
File without changes
@@ -0,0 +1,182 @@
1
+ """
2
+ Convergence analysis for Brownian Dynamics simulations
3
+ ======================================================
4
+
5
+ Physical Background
6
+ -------------------
7
+ BD trajectories are independent Bernoulli trials. Each trajectory
8
+ starts from a fresh random position on the b-surface with an
9
+ independent random number seed, and terminates by either reacting
10
+ (success, probability P_rxn) or escaping (failure, probability
11
+ 1 - P_rxn).
12
+
13
+ Because the trials are independent and identically distributed
14
+ (i.i.d.), the standard error of the mean is exact.
15
+
16
+ SE(P_rxn) = √[P_rxn × (1 - P_rxn) / N]
17
+
18
+ Convergence criterion
19
+ ---------------------
20
+ The relative standard error (RSE = SE / P_rxn) directly quantifies
21
+ the precision of k_on.
22
+
23
+ RSE = √[(1 - P_rxn) / (N × P_rxn)]
24
+
25
+ Since RSE ∝ 1/√N, the number of trajectories needed for a target
26
+ precision is given by
27
+
28
+ N_needed = (1 - P_rxn) / (P_rxn × RSE_target²)
29
+
30
+
31
+
32
+ Wilson score confidence interval
33
+ --------------------------------
34
+ The 95% CI on P_rxn is computed using the Wilson score interval
35
+
36
+ P ∈ [p̂ + z²/2n ± z√(p̂(1-p̂)/n + z²/4n²)] / (1 + z²/n)
37
+
38
+ This is preferred over the normal approximation (p̂ ± 2σ) because
39
+ it provides guaranteed coverage even when P_rxn << 0.05 or N is
40
+ small. The normal approximation gives negative lower bounds for
41
+ small P_rxn, which is unphysical.
42
+ """
43
+
44
+ from typing import Optional
45
+ import math
46
+ import json
47
+ import os
48
+
49
+
50
+ def analyse_convergence(
51
+ n_reacted: int,
52
+ n_escaped: int,
53
+ k_b: float,
54
+ tol: float = 0.05,
55
+ conv_factor: float = 6.022e8,
56
+ work_dir: str = ".",
57
+ ) -> dict:
58
+ """
59
+ Run convergence analysis on completed BD simulation.
60
+
61
+ Parameters
62
+ ----------
63
+ n_reacted : int
64
+ Total trajectories that reacted.
65
+ n_escaped : int
66
+ Total trajectories that escaped.
67
+ k_b : float
68
+ Encounter rate constant (A^3/ps).
69
+ tol : float
70
+ Relative SE threshold for convergence (default 0.05 = 5%).
71
+ conv_factor : float
72
+ Unit conversion A^3/ps -> M-1 s-1.
73
+ work_dir : str
74
+ Directory to save convergence report.
75
+
76
+ Returns
77
+ -------
78
+ dict with convergence results.
79
+ """
80
+ N = n_reacted + n_escaped
81
+ if N == 0:
82
+ return {"converged": False, "reason": "no completed trajectories"}
83
+ P = n_reacted / N
84
+ k_on = conv_factor * k_b * P
85
+ # SE and relative SE
86
+ if P > 0 and P < 1:
87
+ SE = math.sqrt(P * (1 - P) / N)
88
+ relative_SE = SE / P
89
+ elif P == 0:
90
+ SE = 0.0
91
+ relative_SE = float("inf")
92
+ else:
93
+ SE = 0.0
94
+ relative_SE = 0.0
95
+ SE_kon = conv_factor * k_b * SE
96
+ # Wilson 95% CI
97
+ z = 1.96
98
+ denom = 1 + z**2 / N
99
+ centre = (P + z**2 / (2 * N)) / denom
100
+ spread = z * math.sqrt(P * (1 - P) / N + z**2 / (4 * N**2)) / denom
101
+ wilson_lo = max(0.0, centre - spread)
102
+ wilson_hi = min(1.0, centre + spread)
103
+ wilson_lo_kon = conv_factor * k_b * wilson_lo
104
+ wilson_hi_kon = conv_factor * k_b * wilson_hi
105
+ # Convergence verdict
106
+ converged = relative_SE < tol if P > 0 else False
107
+ # N needed for target tolerances
108
+ targets = {}
109
+ if 0 < P < 1:
110
+ for target_tol in [0.10, 0.05, 0.01]:
111
+ n_needed = int(math.ceil((1 - P) / (P * target_tol**2)))
112
+ targets[f"{int(target_tol*100)}%"] = n_needed
113
+ result = {
114
+ "N": N,
115
+ "n_reacted": n_reacted,
116
+ "n_escaped": n_escaped,
117
+ "P_rxn": P,
118
+ "SE": SE,
119
+ "relative_SE": relative_SE,
120
+ "relative_SE_pct": relative_SE * 100 if P > 0 else float("inf"),
121
+ "k_on": k_on,
122
+ "SE_kon": SE_kon,
123
+ "wilson_CI": [wilson_lo_kon, wilson_hi_kon],
124
+ "wilson_CI_P": [wilson_lo, wilson_hi],
125
+ "converged": converged,
126
+ "tol": tol,
127
+ "tol_pct": tol * 100,
128
+ "N_needed": targets,
129
+ }
130
+ return result
131
+
132
+
133
+ def print_convergence(result: dict) -> str:
134
+ """Print convergence analysis to terminal and return as string."""
135
+ lines = []
136
+ lines.append("")
137
+ lines.append(" Convergence analysis")
138
+ if "N" not in result:
139
+ lines.append(f" {result.get('reason', 'no data')}")
140
+ text = "\n".join(lines)
141
+ print(text)
142
+ return text
143
+ lines.append(f" N completed = {result['N']:,}")
144
+ lines.append(f" P_rxn = {result['P_rxn']:.6f}")
145
+ lines.append(f" SE(P_rxn) = {result['SE']:.6f}")
146
+ if result["P_rxn"] > 0:
147
+ lines.append(
148
+ f" Relative SE = {result['relative_SE_pct']:.2f}%"
149
+ f" - k_on known to ±{result['relative_SE_pct']:.2f}%"
150
+ )
151
+ else:
152
+ lines.append(f" Relative SE = inf (P_rxn = 0, no reactions)")
153
+ lines.append(
154
+ f" Wilson 95% CI = [{result['wilson_CI'][0]:.4e}, "
155
+ f"{result['wilson_CI'][1]:.4e}] M⁻¹s⁻¹"
156
+ )
157
+ tol_pct = result["tol_pct"]
158
+ if result["converged"]:
159
+ lines.append(
160
+ f" Converged (relative SE {result['relative_SE_pct']:.2f}% < {tol_pct:.0f}% threshold)"
161
+ )
162
+ else:
163
+ lines.append(
164
+ f" Not converged (relative SE {result['relative_SE_pct']:.2f}% > {tol_pct:.0f}% threshold)"
165
+ )
166
+ if result["N_needed"]:
167
+ lines.append(f" Trajectories needed")
168
+ for label, n in result["N_needed"].items():
169
+ status = "done" if result["N"] >= n else "need more"
170
+ lines.append(f" For ±{label} relative SE: {n:,} ({status})")
171
+ text = "\n".join(lines)
172
+ print(text)
173
+ return text
174
+
175
+
176
+ def save_convergence(result: dict, work_dir: str = ".") -> None:
177
+ import json, os
178
+
179
+ path = os.path.join(work_dir, "convergence.json")
180
+ with open(path, "w") as f:
181
+ json.dump(result, f, indent=2, default=str)
182
+ print(f" Convergence saved -> {path}")
File without changes
@@ -0,0 +1,151 @@
1
+ """
2
+ Auxiliary preprocessing tools for PySTARC.
3
+
4
+ """
5
+
6
+ from __future__ import annotations
7
+ from pystarc.structures.molecules import Atom, Molecule, BoundingBox
8
+ from pystarc.global_defs.constants import BJERRUM_LENGTH
9
+ from typing import List, Tuple, Optional, Dict
10
+ from pystarc.global_defs.constants import PI
11
+ import numpy as np
12
+ import math
13
+
14
+
15
+ # bounding_box
16
+ def bounding_box(mol: Molecule, padding: float = 5.0) -> BoundingBox:
17
+ """
18
+ Compute axis-aligned bounding box of a molecule with optional padding.
19
+ """
20
+ return BoundingBox.from_molecule(mol, padding=padding)
21
+
22
+
23
+ # surface_spheres
24
+ def surface_spheres(
25
+ mol: Molecule, probe_radius: float = 1.4, n_points: int = 92
26
+ ) -> List[np.ndarray]:
27
+ """
28
+ Generate surface probe positions using a Fibonacci sphere around each atom.
29
+ Returns list of (x,y,z) probe positions on the molecular surface.
30
+ """
31
+ positions = []
32
+ golden = (1 + math.sqrt(5)) / 2
33
+ for atom in mol.atoms:
34
+ r = atom.radius + probe_radius
35
+ c = atom.position
36
+ for i in range(n_points):
37
+ theta = math.acos(1 - 2 * (i + 0.5) / n_points)
38
+ phi = 2 * PI * i / golden
39
+ x = c[0] + r * math.sin(theta) * math.cos(phi)
40
+ y = c[1] + r * math.sin(theta) * math.sin(phi)
41
+ z = c[2] + r * math.cos(theta)
42
+ # Check not inside any other atom
43
+ p = np.array([x, y, z])
44
+ buried = any(
45
+ np.linalg.norm(p - a.position) < a.radius + probe_radius
46
+ for a in mol.atoms
47
+ if a is not atom
48
+ )
49
+ if not buried:
50
+ positions.append(p)
51
+ return positions
52
+
53
+
54
+ # lumped_charges
55
+ def lumped_charges(
56
+ mol: Molecule, grid_spacing: float = 2.0
57
+ ) -> List[Tuple[np.ndarray, float]]:
58
+ """
59
+ Coarse-grain atomic charges onto a regular grid by nearest-grid-point.
60
+ Returns list of (position, charge) tuples for non-zero grid points.
61
+ """
62
+ if not mol.atoms:
63
+ return []
64
+ bb = bounding_box(mol, padding=grid_spacing)
65
+ # Build grid
66
+ xs = np.arange(bb.xmin, bb.xmax + grid_spacing, grid_spacing)
67
+ ys = np.arange(bb.ymin, bb.ymax + grid_spacing, grid_spacing)
68
+ zs = np.arange(bb.zmin, bb.zmax + grid_spacing, grid_spacing)
69
+ grid: Dict[Tuple[int, int, int], float] = {}
70
+ for atom in mol.atoms:
71
+ if atom.charge == 0.0:
72
+ continue
73
+ ix = int(round((atom.x - bb.xmin) / grid_spacing))
74
+ iy = int(round((atom.y - bb.ymin) / grid_spacing))
75
+ iz = int(round((atom.z - bb.zmin) / grid_spacing))
76
+ key = (ix, iy, iz)
77
+ grid[key] = grid.get(key, 0.0) + atom.charge
78
+ result = []
79
+ for (ix, iy, iz), q in grid.items():
80
+ if abs(q) > 1e-8:
81
+ pos = np.array(
82
+ [
83
+ bb.xmin + ix * grid_spacing,
84
+ bb.ymin + iy * grid_spacing,
85
+ bb.zmin + iz * grid_spacing,
86
+ ]
87
+ )
88
+ result.append((pos, q))
89
+ return result
90
+
91
+
92
+ # electrostatic_center
93
+ def electrostatic_center(mol: Molecule) -> np.ndarray:
94
+ """
95
+ Charge-weighted center of a molecule.
96
+ Falls back to geometric centroid if total charge is zero.
97
+ """
98
+ total_q = sum(abs(a.charge) for a in mol.atoms)
99
+ if total_q < 1e-10:
100
+ return mol.centroid()
101
+ pos = mol.positions_array()
102
+ charges = np.abs(mol.charges_array())
103
+ return (pos * charges[:, None]).sum(axis=0) / total_q
104
+
105
+
106
+ # hydrodynamic_radius
107
+ def hydrodynamic_radius_from_rg(mol: Molecule) -> float:
108
+ """
109
+ Approximate hydrodynamic radius from radius of gyration.
110
+ r_h ≈ 0.77 × r_g (empirical for globular proteins).
111
+ """
112
+ return 0.77 * mol.radius_of_gyration()
113
+
114
+
115
+ def hydrodynamic_radius_from_surface(mol: Molecule) -> float:
116
+ """
117
+ Approximate hydrodynamic radius as bounding radius of the molecule.
118
+ """
119
+ return mol.bounding_radius()
120
+
121
+
122
+ # contact_distances
123
+ def contact_distances(
124
+ mol1: Molecule, mol2: Molecule, cutoff: float = 8.0
125
+ ) -> List[Tuple[int, int, float]]:
126
+ """
127
+ Return all atom pairs (i, j, dist) within cutoff Å.
128
+ Used to auto-generate reaction contacts.
129
+ """
130
+ pairs = []
131
+ for i, a1 in enumerate(mol1.atoms):
132
+ for j, a2 in enumerate(mol2.atoms):
133
+ d = a1.distance_to(a2)
134
+ if d <= cutoff:
135
+ pairs.append((i, j, d))
136
+ pairs.sort(key=lambda t: t[2])
137
+ return pairs
138
+
139
+
140
+ # born_integral
141
+ def born_integral(
142
+ charge: float, radius: float, eps_in: float = 4.0, eps_out: float = 78.54
143
+ ) -> float:
144
+ """
145
+ Born solvation energy of a sphere:
146
+ ΔG_Born = -(q²/8π ε₀) × (1/ε_in - 1/ε_out) / r [kBT]
147
+ Returns energy in kBT (using Bjerrum length scale).
148
+ """
149
+ if radius < 1e-10:
150
+ return 0.0
151
+ return -(charge**2 * BJERRUM_LENGTH / (2 * radius)) * (1.0 / eps_in - 1.0 / eps_out)
File without changes
pystarc/cli/main.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Command-line interface for PySTARC.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from pystarc.simulation.nam_simulator import NAMSimulator, NAMParameters
7
+ from pystarc.hydrodynamics.rotne_prager import MobilityTensor
8
+ from pystarc.xml_io.simulation_io import parse_reaction_xml
9
+ from pystarc.forces.electrostatic.grid_force import DXGrid
10
+ from pystarc.simulation.nam_simulator import zero_force
11
+ from pystarc.structures.pqr_io import parse_pqr
12
+ from pystarc.aux.aux_tools import bounding_box
13
+ import xml.etree.ElementTree as ET
14
+ from pathlib import Path
15
+ import numpy as np
16
+ import click
17
+ import sys
18
+
19
+
20
+ @click.group()
21
+ @click.version_option(package_name="pystarc")
22
+ def cli():
23
+ """PySTARC - Python Simulation Toolkit for Association Rate Constants"""
24
+ pass
25
+
26
+
27
+ # nam_simulation
28
+ @cli.command("nam_simulation")
29
+ @click.option("--mol1", required=True, help="PQR file for molecule 1")
30
+ @click.option("--mol2", required=True, help="PQR file for molecule 2")
31
+ @click.option("--rxn", required=True, help="Reaction XML file")
32
+ @click.option("--n", default=1000, show_default=True, help="Number of trajectories")
33
+ @click.option("--dt", default=0.2, show_default=True, help="Time step (ps)")
34
+ @click.option("--r-start", default=100.0, show_default=True, help="Start radius (Å)")
35
+ @click.option("--dx", multiple=True, help="APBS .dx grid file(s)")
36
+ @click.option("--seed", default=None, type=int, help="Random seed")
37
+ @click.option("--verbose", is_flag=True, help="Print progress")
38
+ @click.option("--output", default="results.xml", help="Output XML file")
39
+ def nam_simulation(mol1, mol2, rxn, n, dt, r_start, dx, seed, verbose, output):
40
+ """Run a NAM Brownian dynamics simulation."""
41
+ click.echo(f"Loading molecules …")
42
+ m1 = parse_pqr(mol1)
43
+ m2 = parse_pqr(mol2)
44
+ click.echo(f" mol1: {m1}")
45
+ click.echo(f" mol2: {m2}")
46
+ pathways = parse_reaction_xml(rxn)
47
+ click.echo(f" reactions: {pathways}")
48
+ # Mobility from bounding radii
49
+ r1 = m1.bounding_radius()
50
+ r2 = m2.bounding_radius()
51
+ mobility = MobilityTensor.from_radii(r1, r2)
52
+ click.echo(f" mobility: {mobility}")
53
+ # Load DX grids if provided
54
+ grids = []
55
+ for dx_file in dx:
56
+ g = DXGrid.from_file(dx_file)
57
+ grids.append(g)
58
+ click.echo(f" loaded grid: {g}")
59
+ # Build force function
60
+ if grids:
61
+
62
+ def force_fn(mol_1, mol_2):
63
+ force = np.zeros(3)
64
+ torque = np.zeros(3)
65
+ energy = 0.0
66
+ for grid in grids:
67
+ for atom in mol_2.atoms:
68
+ if abs(atom.charge) < 1e-9:
69
+ continue
70
+ f = grid.force_on_charge(atom.position, atom.charge)
71
+ force += f
72
+ energy += grid.interpolate(atom.position) * atom.charge
73
+ # torque = r × f
74
+ r = atom.position - mol_2.centroid()
75
+ torque += np.cross(r, f)
76
+ return force, torque, energy
77
+
78
+ else:
79
+ force_fn = zero_force
80
+ params = NAMParameters(
81
+ n_trajectories=n,
82
+ dt=dt,
83
+ r_start=r_start,
84
+ seed=seed,
85
+ verbose=verbose,
86
+ )
87
+ sim = NAMSimulator(m1, m2, mobility, pathways, params, force_fn)
88
+ click.echo(f"\nRunning {n} trajectories …")
89
+ result = sim.run()
90
+ click.echo(f"\n{'-'*50}")
91
+ click.echo(f"Results:")
92
+ click.echo(f" Reacted : {result.n_reacted}")
93
+ click.echo(f" Escaped : {result.n_escaped}")
94
+ click.echo(f" P(rxn) : {result.reaction_probability:.4f}")
95
+ D_rel = mobility.relative_translational_diffusion()
96
+ k = result.rate_constant(D_rel)
97
+ click.echo(f" k_assoc : {k:.3e} M⁻¹s⁻¹")
98
+ click.echo(f"{'-'*50}")
99
+
100
+
101
+ # bounding_box
102
+ @cli.command("bounding_box")
103
+ @click.argument("pqr_file")
104
+ @click.option("--padding", default=5.0, show_default=True, help="Padding in Å")
105
+ def bounding_box_cmd(pqr_file, padding):
106
+ """Print bounding box of a PQR molecule."""
107
+ mol = parse_pqr(pqr_file)
108
+ bb = bounding_box(mol, padding)
109
+ click.echo(f"Bounding box for {pqr_file}:")
110
+ click.echo(f" x: [{bb.xmin:.3f}, {bb.xmax:.3f}]")
111
+ click.echo(f" y: [{bb.ymin:.3f}, {bb.ymax:.3f}]")
112
+ click.echo(f" z: [{bb.zmin:.3f}, {bb.zmax:.3f}]")
113
+ click.echo(f" center: {bb.center}")
114
+ click.echo(f" size: {bb.size}")
115
+
116
+
117
+ # pqr_to_xml
118
+ @cli.command("pqr_to_xml")
119
+ @click.argument("pqr_file")
120
+ @click.option("--output", "-o", default=None, help="Output XML file")
121
+ def pqr_to_xml(pqr_file, output):
122
+ """Convert a PQR file to PySTARC molecule XML format."""
123
+ mol = parse_pqr(pqr_file)
124
+ root = ET.Element("molecule", name=mol.name)
125
+ for a in mol.atoms:
126
+ ET.SubElement(
127
+ root,
128
+ "atom",
129
+ index=str(a.index),
130
+ name=a.name,
131
+ resname=a.residue_name,
132
+ resid=str(a.residue_index),
133
+ x=f"{a.x:.4f}",
134
+ y=f"{a.y:.4f}",
135
+ z=f"{a.z:.4f}",
136
+ charge=f"{a.charge:.4f}",
137
+ radius=f"{a.radius:.4f}",
138
+ )
139
+ tree = ET.ElementTree(root)
140
+ ET.indent(tree, space=" ")
141
+ out = output or (Path(pqr_file).stem + ".xml")
142
+ tree.write(out, encoding="unicode", xml_declaration=True)
143
+ click.echo(f"Written: {out} ({len(mol.atoms)} atoms)")
144
+
145
+
146
+ def main():
147
+ cli()
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
File without changes
File without changes