rust-simulation-tools 0.2.2__tar.gz → 0.2.4__tar.gz

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 (44) hide show
  1. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/PKG-INFO +1 -1
  2. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/bindings/src/lib.rs +9 -1
  3. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/gb_energy.rs +29 -13
  4. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/mm_energy.rs +6 -4
  5. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/pb_energy.rs +236 -62
  6. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/pb_grid.rs +333 -21
  7. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/pb_solver.rs +65 -3
  8. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/pyproject.toml +1 -1
  9. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/Cargo.lock +0 -0
  10. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/Cargo.toml +0 -0
  11. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/README.md +0 -0
  12. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/bindings/Cargo.toml +0 -0
  13. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/Cargo.toml +0 -0
  14. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/benches/core_bench.rs +0 -0
  15. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/amber/inpcrd.rs +0 -0
  16. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/amber/mod.rs +0 -0
  17. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/amber/prmtop.rs +0 -0
  18. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/fingerprint.rs +0 -0
  19. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/kabsch.rs +0 -0
  20. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/lib.rs +0 -0
  21. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/sasa.rs +0 -0
  22. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/ast.rs +0 -0
  23. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/error.rs +0 -0
  24. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/eval.rs +0 -0
  25. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/keywords.rs +0 -0
  26. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/mod.rs +0 -0
  27. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/parser.rs +0 -0
  28. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/selection.rs +0 -0
  29. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/selection/token.rs +0 -0
  30. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/trajectory/dcd.rs +0 -0
  31. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/trajectory/mod.rs +0 -0
  32. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/util.rs +0 -0
  33. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/core/src/wrapping.rs +0 -0
  34. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/Cargo.toml +0 -0
  35. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/benches/mmpbsa_bench.rs +0 -0
  36. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/examples/bench_pb_real.rs +0 -0
  37. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/examples/mmpbsa_binding.rs +0 -0
  38. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/binding.rs +0 -0
  39. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/decomposition.rs +0 -0
  40. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/entropy.rs +0 -0
  41. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/lib.rs +0 -0
  42. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/mdcrd.rs +0 -0
  43. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/sa_energy.rs +0 -0
  44. {rust_simulation_tools-0.2.2 → rust_simulation_tools-0.2.4}/crates/mmpbsa/src/subsystem.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust_simulation_tools
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Requires-Dist: numpy>=1.26
@@ -1539,7 +1539,7 @@ struct PyPbParams {
1539
1539
  #[pymethods]
1540
1540
  impl PyPbParams {
1541
1541
  #[new]
1542
- #[pyo3(signature = (grid_spacing=None, grid_buffer=None, solute_dielectric=None, solvent_dielectric=None, salt_concentration=None, temperature=None, probe_radius=None, ion_radius=None, tolerance=None, max_iterations=None))]
1542
+ #[pyo3(signature = (grid_spacing=None, grid_buffer=None, solute_dielectric=None, solvent_dielectric=None, salt_concentration=None, temperature=None, probe_radius=None, ion_radius=None, tolerance=None, max_iterations=None, fillratio=None, fscale=None))]
1543
1543
  fn new(
1544
1544
  grid_spacing: Option<f64>,
1545
1545
  grid_buffer: Option<f64>,
@@ -1551,6 +1551,8 @@ impl PyPbParams {
1551
1551
  ion_radius: Option<f64>,
1552
1552
  tolerance: Option<f64>,
1553
1553
  max_iterations: Option<usize>,
1554
+ fillratio: Option<f64>,
1555
+ fscale: Option<usize>,
1554
1556
  ) -> Self {
1555
1557
  let mut params = PbParams::default();
1556
1558
  if let Some(v) = grid_spacing {
@@ -1583,6 +1585,12 @@ impl PyPbParams {
1583
1585
  if let Some(v) = max_iterations {
1584
1586
  params.max_iterations = v;
1585
1587
  }
1588
+ if let Some(v) = fillratio {
1589
+ params.fillratio = v;
1590
+ }
1591
+ if let Some(v) = fscale {
1592
+ params.fscale = v;
1593
+ }
1586
1594
  PyPbParams { inner: params }
1587
1595
  }
1588
1596
 
@@ -241,7 +241,7 @@ fn compute_born_radii(
241
241
  let psi2 = psi_rho * psi_rho;
242
242
  let psi3 = psi2 * psi_rho;
243
243
  let tanh_val = (alpha * psi_rho - beta * psi2 + gamma * psi3).tanh();
244
- let inv_r = 1.0 / rho_i - tanh_val / rho_i;
244
+ let inv_r = 1.0 / rho_i - tanh_val / topology.radii[i];
245
245
  born_radii[i] = if inv_r > 0.0 {
246
246
  1.0 / inv_r
247
247
  } else {
@@ -267,10 +267,14 @@ fn compute_born_radii(
267
267
  /// # Returns
268
268
  /// `GbEnergy` containing the total energy in kcal/mol and per-atom Born radii.
269
269
  ///
270
- /// The GB energy is computed as:
271
- /// E_GB = -½(1/ε_in - 1/ε_out) Σ_{i,j} q_i·q_j·exp(-κ·f_GB) / f_GB
270
+ /// The GB energy is computed using the AMBER convention:
271
+ /// E_GB = -½ Σ_{i,j} q_i·q_j · (1/ε_in - exp(-KSCALE·κ·f_GB)/ε_out) / f_GB
272
272
  ///
273
273
  /// where f_GB = sqrt(r²_ij + R_i·R_j·exp(-r²_ij/(4·R_i·R_j)))
274
+ /// and KSCALE = 0.73 (Srinivasan et al.)
275
+ ///
276
+ /// When salt_concentration = 0 (κ = 0), this reduces to:
277
+ /// E_GB = -½(1/ε_in - 1/ε_out) Σ_{i,j} q_i·q_j / f_GB
274
278
  ///
275
279
  /// Charges are taken from `charges_amber` (AMBER internal units where
276
280
  /// q_amber = q_real × 18.2223, so q_i·q_j already yields kcal·Å/mol when
@@ -283,23 +287,34 @@ fn compute_gb_energy_from_radii(
283
287
  born_radii: &[f64],
284
288
  ) -> f64 {
285
289
  let n = topology.n_atoms;
286
- let dielectric_factor =
287
- -0.5 * (1.0 / params.solute_dielectric - 1.0 / params.solvent_dielectric);
288
290
  let kappa = compute_kappa(
289
291
  params.salt_concentration,
290
292
  params.solvent_dielectric,
291
293
  params.temperature,
292
294
  );
293
295
  let cutoff_sq = params.cutoff * params.cutoff;
296
+ let inv_ein = 1.0 / params.solute_dielectric;
297
+ let inv_eout = 1.0 / params.solvent_dielectric;
298
+
299
+ // AMBER Debye-Hückel scaling factor (Srinivasan et al.)
300
+ const KSCALE: f64 = 0.73;
301
+
302
+ // Dielectric factor depends on whether salt screening is active:
303
+ // With salt: E = -0.5 * q_i*q_j * (1/ε_in - exp(-KSCALE*κ*f)/ε_out) / f
304
+ // Without salt: E = -0.5 * q_i*q_j * (1/ε_in - 1/ε_out) / f
305
+ let no_salt = kappa <= 0.0;
306
+ let dielectric_no_salt = -0.5 * (inv_ein - inv_eout);
294
307
 
295
308
  // Self-energy terms (O(N), kept serial)
296
309
  let mut energy = 0.0;
297
310
  for i in 0..n {
298
311
  let qi = topology.charges_amber[i];
299
- let mut self_energy = dielectric_factor * qi * qi / born_radii[i];
300
- if kappa > 0.0 {
301
- self_energy *= (-kappa * born_radii[i]).exp();
302
- }
312
+ let self_energy = if no_salt {
313
+ dielectric_no_salt * qi * qi / born_radii[i]
314
+ } else {
315
+ -0.5 * qi * qi * (inv_ein - (-KSCALE * kappa * born_radii[i]).exp() * inv_eout)
316
+ / born_radii[i]
317
+ };
303
318
  energy += self_energy;
304
319
  }
305
320
 
@@ -324,10 +339,11 @@ fn compute_gb_energy_from_radii(
324
339
  let ri_rj = born_radii[i] * born_radii[j];
325
340
  let f_gb = (r2 + ri_rj * (-r2 / (4.0 * ri_rj)).exp()).sqrt();
326
341
 
327
- let mut pair_energy = dielectric_factor * qi * qj / f_gb;
328
- if kappa > 0.0 {
329
- pair_energy *= (-kappa * f_gb).exp();
330
- }
342
+ let pair_energy = if no_salt {
343
+ dielectric_no_salt * qi * qj / f_gb
344
+ } else {
345
+ -0.5 * qi * qj * (inv_ein - (-KSCALE * kappa * f_gb).exp() * inv_eout) / f_gb
346
+ };
331
347
 
332
348
  sum += 2.0 * pair_energy;
333
349
  }
@@ -201,13 +201,14 @@ pub fn compute_mm_energy_with_nb(
201
201
  energy.angle += ka * (theta - theta_eq) * (theta - theta_eq);
202
202
  }
203
203
 
204
- // Dihedral energy: E = (V_n/2) * [1 + cos(n*phi - gamma)]
204
+ // Dihedral energy: E = pk * [1 + cos(n*phi - gamma)]
205
+ // AMBER prmtop DIHEDRAL_FORCE_CONSTANT stores pk (already Vn/2, the half-barrier height)
205
206
  for &(i, j, k, l, type_idx, _ignore_14) in &topology.dihedrals {
206
- let vn = topology.dihedral_force_constants[type_idx];
207
+ let pk = topology.dihedral_force_constants[type_idx];
207
208
  let n = topology.dihedral_periodicities[type_idx];
208
209
  let gamma = topology.dihedral_phases[type_idx];
209
210
  let phi = compute_dihedral(&coords[i], &coords[j], &coords[k], &coords[l]);
210
- energy.dihedral += (vn / 2.0) * (1.0 + (n * phi - gamma).cos());
211
+ energy.dihedral += pk * (1.0 + (n * phi - gamma).cos());
211
212
  }
212
213
 
213
214
  // Non-bonded interactions (parallelized over outer atom index)
@@ -380,7 +381,8 @@ mod tests {
380
381
  top.lj_acoef = Arc::new(vec![0.0]);
381
382
  top.lj_bcoef = Arc::new(vec![0.0]);
382
383
 
383
- // Trans dihedral (phi=PI): E = (2/2) * [1 + cos(2*PI - PI)] = 1 * [1 + cos(PI)] = 0
384
+ // Trans dihedral (phi=PI): E = 2 * [1 + cos(2*PI - PI)] = 2 * [1 + cos(PI)] = 0
385
+ // (pk=2.0 is already the half-barrier height in AMBER prmtop convention)
384
386
  let coords = [
385
387
  [1.0, 1.0, 0.0],
386
388
  [0.0, 0.0, 0.0],
@@ -7,8 +7,12 @@ use rayon;
7
7
 
8
8
  use crate::pb_grid::{
9
9
  assign_dielectrics, assign_kappa, auto_grid, map_charges, topology_charges, topology_radii,
10
+ PbGrid,
11
+ };
12
+ use crate::pb_solver::{
13
+ compute_elec_energy, interpolated_boundary, solve_lpbe_multigrid, BoundaryCondition,
14
+ PbSolveResult,
10
15
  };
11
- use crate::pb_solver::{compute_elec_energy, solve_lpbe_multigrid, BoundaryCondition};
12
16
  use crate::subsystem::{extract_coords, extract_subtopology};
13
17
  use rst_core::amber::prmtop::AmberTopology;
14
18
 
@@ -35,6 +39,11 @@ pub struct PbParams {
35
39
  pub tolerance: f64,
36
40
  /// Maximum SOR iterations. Default: 10000.
37
41
  pub max_iterations: usize,
42
+ /// Ratio of coarse grid extent to molecule extent for focusing. Default: 4.0.
43
+ /// Set to 0.0 to disable focusing (not recommended for large molecules).
44
+ pub fillratio: f64,
45
+ /// Ratio of coarse grid spacing to fine grid spacing. Default: 8.
46
+ pub fscale: usize,
38
47
  }
39
48
 
40
49
  impl Default for PbParams {
@@ -50,6 +59,8 @@ impl Default for PbParams {
50
59
  ion_radius: 2.0,
51
60
  tolerance: 1e-6,
52
61
  max_iterations: 10000,
62
+ fillratio: 4.0,
63
+ fscale: 8,
53
64
  }
54
65
  }
55
66
  }
@@ -74,13 +85,191 @@ pub(crate) fn compute_kappa(salt_conc: f64, solvent_dielectric: f64, temperature
74
85
  (factor * salt_conc / (solvent_dielectric * kb * temperature)).sqrt()
75
86
  }
76
87
 
88
+ /// Build a coarse grid for focusing.
89
+ ///
90
+ /// The coarse grid covers `fillratio` × the molecule extent, with spacing
91
+ /// equal to `fscale` × the fine grid spacing.
92
+ fn build_coarse_grid(
93
+ coords: &[[f64; 3]],
94
+ fine_spacing: f64,
95
+ fillratio: f64,
96
+ fscale: usize,
97
+ ) -> PbGrid {
98
+ let mut min = [f64::MAX; 3];
99
+ let mut max = [f64::MIN; 3];
100
+ for c in coords {
101
+ for d in 0..3 {
102
+ if c[d] < min[d] {
103
+ min[d] = c[d];
104
+ }
105
+ if c[d] > max[d] {
106
+ max[d] = c[d];
107
+ }
108
+ }
109
+ }
110
+
111
+ let coarse_spacing = fine_spacing * fscale as f64;
112
+ let mut dims = [0usize; 3];
113
+ let mut origin = [0.0f64; 3];
114
+ for d in 0..3 {
115
+ let mol_extent = max[d] - min[d];
116
+ let grid_extent = mol_extent * fillratio;
117
+ let n = (grid_extent / coarse_spacing).ceil() as usize + 1;
118
+ dims[d] = if n.is_multiple_of(2) { n + 1 } else { n };
119
+ let actual_extent = (dims[d] - 1) as f64 * coarse_spacing;
120
+ let center = 0.5 * (min[d] + max[d]);
121
+ origin[d] = center - 0.5 * actual_extent;
122
+ }
123
+
124
+ PbGrid::new(dims, [coarse_spacing; 3], origin)
125
+ }
126
+
127
+ /// Perform a single PB solve (solvated or reference) with optional focusing.
128
+ ///
129
+ /// If `coarse_grid` is provided, first solves on the coarse grid, then
130
+ /// interpolates boundary conditions for the fine grid.
131
+ fn pb_solve_with_focusing(
132
+ fine_grid: &PbGrid,
133
+ coarse_grid: Option<&PbGrid>,
134
+ coords: &[[f64; 3]],
135
+ charges: &[f64],
136
+ radii: &[f64],
137
+ params: &PbParams,
138
+ eps_in: f64,
139
+ eps_out: f64,
140
+ kappa: f64,
141
+ use_ionic: bool,
142
+ ) -> (PbSolveResult, f64) {
143
+ // Determine DH BC parameters for the initial (or only) solve
144
+ let bc_kappa = if use_ionic { kappa } else { 0.0 };
145
+
146
+ match coarse_grid {
147
+ Some(cgrid) => {
148
+ // === Two-level focusing ===
149
+ // 1. Solve on coarse grid with DH/Coulomb BCs
150
+ let coarse_charges = map_charges(cgrid, coords, charges);
151
+ let coarse_diel =
152
+ assign_dielectrics(cgrid, coords, radii, params.probe_radius, eps_in, eps_out);
153
+ let coarse_kappa = if use_ionic {
154
+ assign_kappa(
155
+ cgrid,
156
+ coords,
157
+ radii,
158
+ params.ion_radius,
159
+ kappa,
160
+ params.solvent_dielectric,
161
+ )
162
+ } else {
163
+ vec![0.0; cgrid.len()]
164
+ };
165
+
166
+ let coarse_result = solve_lpbe_multigrid(
167
+ cgrid,
168
+ &coarse_charges,
169
+ &coarse_diel,
170
+ &coarse_kappa,
171
+ BoundaryCondition::DebyeHuckel,
172
+ coords,
173
+ charges,
174
+ bc_kappa,
175
+ eps_out,
176
+ params.tolerance * 10.0, // coarser tolerance for speed
177
+ params.max_iterations,
178
+ );
179
+
180
+ // 2. Interpolate coarse solution as BCs for fine grid
181
+ let fine_bc = interpolated_boundary(fine_grid, cgrid, &coarse_result.potential);
182
+
183
+ // 3. Solve on fine grid with interpolated BCs
184
+ let fine_charges = map_charges(fine_grid, coords, charges);
185
+ let fine_diel = assign_dielectrics(
186
+ fine_grid,
187
+ coords,
188
+ radii,
189
+ params.probe_radius,
190
+ eps_in,
191
+ eps_out,
192
+ );
193
+ let fine_kappa = if use_ionic {
194
+ assign_kappa(
195
+ fine_grid,
196
+ coords,
197
+ radii,
198
+ params.ion_radius,
199
+ kappa,
200
+ params.solvent_dielectric,
201
+ )
202
+ } else {
203
+ vec![0.0; fine_grid.len()]
204
+ };
205
+
206
+ let result = solve_lpbe_multigrid(
207
+ fine_grid,
208
+ &fine_charges,
209
+ &fine_diel,
210
+ &fine_kappa,
211
+ BoundaryCondition::Interpolated(fine_bc),
212
+ coords,
213
+ charges,
214
+ bc_kappa,
215
+ eps_out,
216
+ params.tolerance,
217
+ params.max_iterations,
218
+ );
219
+ let energy = compute_elec_energy(fine_grid, &result.potential, coords, charges);
220
+ (result, energy)
221
+ }
222
+ None => {
223
+ // === Single-level solve ===
224
+ let charge_map = map_charges(fine_grid, coords, charges);
225
+ let diel = assign_dielectrics(
226
+ fine_grid,
227
+ coords,
228
+ radii,
229
+ params.probe_radius,
230
+ eps_in,
231
+ eps_out,
232
+ );
233
+ let kappa_map = if use_ionic {
234
+ assign_kappa(
235
+ fine_grid,
236
+ coords,
237
+ radii,
238
+ params.ion_radius,
239
+ kappa,
240
+ params.solvent_dielectric,
241
+ )
242
+ } else {
243
+ vec![0.0; fine_grid.len()]
244
+ };
245
+
246
+ let result = solve_lpbe_multigrid(
247
+ fine_grid,
248
+ &charge_map,
249
+ &diel,
250
+ &kappa_map,
251
+ BoundaryCondition::DebyeHuckel,
252
+ coords,
253
+ charges,
254
+ bc_kappa,
255
+ eps_out,
256
+ params.tolerance,
257
+ params.max_iterations,
258
+ );
259
+ let energy = compute_elec_energy(fine_grid, &result.potential, coords, charges);
260
+ (result, energy)
261
+ }
262
+ }
263
+ }
264
+
77
265
  /// Compute PB polar solvation energy for a molecular system.
78
266
  ///
79
- /// Performs two solves:
80
- /// 1. Solvated: ε_in inside molecule, ε_out outside, with ionic strength
81
- /// 2. Reference: ε_in everywhere (vacuum), no ionic strength
267
+ /// Uses two-level focusing (when `fillratio > 0`):
268
+ /// 1. Coarse solve on a large grid with DH/Coulomb boundary conditions
269
+ /// 2. Fine solve with boundary conditions interpolated from the coarse solution
82
270
  ///
83
- /// Returns ΔG_PB = E_solvated - E_reference (reaction field energy).
271
+ /// Performs paired solves (solvated + reference) and returns
272
+ /// ΔG_PB = E_solvated - E_reference (reaction field energy).
84
273
  pub fn compute_pb_energy(
85
274
  topology: &AmberTopology,
86
275
  coords: &[[f64; 3]],
@@ -95,77 +284,60 @@ pub fn compute_pb_energy(
95
284
  params.temperature,
96
285
  );
97
286
 
98
- // Build grid
99
- let grid = auto_grid(coords, params.grid_spacing, params.grid_buffer);
100
- log::info!(
101
- "PB grid: {}x{}x{} ({} points) for {} atoms",
102
- grid.dims[0],
103
- grid.dims[1],
104
- grid.dims[2],
105
- grid.len(),
106
- coords.len()
107
- );
287
+ // Build fine grid (centered on molecule with buffer)
288
+ let fine_grid = auto_grid(coords, params.grid_spacing, params.grid_buffer);
108
289
 
109
- // Map charges onto grid
110
- let charge_map = map_charges(&grid, coords, &charges);
290
+ // Build coarse grid for focusing (if enabled)
291
+ let coarse_grid = if params.fillratio > 0.0 && params.fscale > 1 {
292
+ let cg = build_coarse_grid(coords, params.grid_spacing, params.fillratio, params.fscale);
293
+ log::info!(
294
+ "PB focusing: coarse {}x{}x{} (spacing {:.1}), fine {}x{}x{} (spacing {:.1}) for {} atoms",
295
+ cg.dims[0], cg.dims[1], cg.dims[2], cg.spacing[0],
296
+ fine_grid.dims[0], fine_grid.dims[1], fine_grid.dims[2], fine_grid.spacing[0],
297
+ coords.len()
298
+ );
299
+ Some(cg)
300
+ } else {
301
+ log::info!(
302
+ "PB grid: {}x{}x{} ({} points) for {} atoms",
303
+ fine_grid.dims[0],
304
+ fine_grid.dims[1],
305
+ fine_grid.dims[2],
306
+ fine_grid.len(),
307
+ coords.len()
308
+ );
309
+ None
310
+ };
111
311
 
112
312
  // Run solvated and reference solves in parallel
113
313
  let ((result_solv, e_solv), (result_ref, e_ref)) = rayon::join(
114
314
  || {
115
- // === Solvated solve ===
116
- let diel_solv = assign_dielectrics(
117
- &grid,
315
+ pb_solve_with_focusing(
316
+ &fine_grid,
317
+ coarse_grid.as_ref(),
118
318
  coords,
319
+ &charges,
119
320
  &radii,
120
- params.probe_radius,
321
+ params,
121
322
  params.solute_dielectric,
122
323
  params.solvent_dielectric,
123
- );
124
- let kappa_sq_solv = assign_kappa(&grid, coords, &radii, params.ion_radius, kappa);
125
-
126
- let result = solve_lpbe_multigrid(
127
- &grid,
128
- &charge_map,
129
- &diel_solv,
130
- &kappa_sq_solv,
131
- BoundaryCondition::DebyeHuckel,
132
- coords,
133
- &charges,
134
324
  kappa,
135
- params.solvent_dielectric,
136
- params.tolerance,
137
- params.max_iterations,
138
- );
139
- let energy = compute_elec_energy(&grid, &result.potential, coords, &charges);
140
- (result, energy)
325
+ true, // use ionic strength
326
+ )
141
327
  },
142
328
  || {
143
- // === Reference (vacuum) solve ===
144
- let diel_ref = assign_dielectrics(
145
- &grid,
146
- coords,
147
- &radii,
148
- params.probe_radius,
149
- params.solute_dielectric,
150
- params.solute_dielectric,
151
- );
152
- let kappa_sq_ref = vec![0.0; grid.len()];
153
-
154
- let result = solve_lpbe_multigrid(
155
- &grid,
156
- &charge_map,
157
- &diel_ref,
158
- &kappa_sq_ref,
159
- BoundaryCondition::Zero,
329
+ pb_solve_with_focusing(
330
+ &fine_grid,
331
+ coarse_grid.as_ref(),
160
332
  coords,
161
333
  &charges,
162
- 0.0,
334
+ &radii,
335
+ params,
163
336
  params.solute_dielectric,
164
- params.tolerance,
165
- params.max_iterations,
166
- );
167
- let energy = compute_elec_energy(&grid, &result.potential, coords, &charges);
168
- (result, energy)
337
+ params.solute_dielectric, // uniform ε_in for reference
338
+ kappa,
339
+ false, // no ionic strength
340
+ )
169
341
  },
170
342
  );
171
343
 
@@ -186,11 +358,13 @@ pub fn compute_pb_energy(
186
358
  }
187
359
 
188
360
  log::debug!(
189
- "PB energies: E_solv = {:.6} (iters={}), E_ref = {:.6} (iters={}), delta = {:.6}",
361
+ "PB energies: E_solv = {:.4} (iters={}, res={:.2e}), E_ref = {:.4} (iters={}, res={:.2e}), delta = {:.4}",
190
362
  e_solv,
191
363
  result_solv.iterations,
364
+ result_solv.final_residual,
192
365
  e_ref,
193
366
  result_ref.iterations,
367
+ result_ref.final_residual,
194
368
  e_solv - e_ref
195
369
  );
196
370
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  use rayon::prelude::*;
8
8
  use rst_core::amber::prmtop::AmberTopology;
9
+ use std::collections::VecDeque;
9
10
 
10
11
  /// A regular 3D grid for finite-difference PB calculations.
11
12
  #[derive(Debug, Clone)]
@@ -317,26 +318,48 @@ fn point_inside_molecule(
317
318
  false
318
319
  }
319
320
 
320
- /// Assign face-centered dielectric values based on molecular surface.
321
+ /// Compute Solvent-Excluded Surface (SES) inside/outside classification.
321
322
  ///
322
- /// Uses a simple approach: for each grid edge, test both endpoints.
323
- /// If both are inside the molecule eps_in, both outside eps_out,
324
- /// mixed harmonic mean.
325
- pub fn assign_dielectrics(
323
+ /// The SES (molecular surface) is the boundary between regions accessible
324
+ /// to a probe sphere of the given radius and regions that are not. This is
325
+ /// tighter than the SAS (Solvent-Accessible Surface) because crevices and
326
+ /// grooves between atoms are classified as interior even though they are
327
+ /// outside the union of inflated spheres.
328
+ ///
329
+ /// Algorithm:
330
+ /// 1. Identify valid probe-center positions: grid points outside the SAS
331
+ /// (where a probe sphere wouldn't overlap any atom) connected to the
332
+ /// bulk (grid boundary) via a path of such positions.
333
+ /// 2. A grid point is outside the SES if any valid probe center within
334
+ /// `probe_radius` can reach it; otherwise it is inside.
335
+ fn compute_inside_ses(
326
336
  grid: &PbGrid,
327
337
  coords: &[[f64; 3]],
328
338
  radii: &[f64],
329
339
  probe_radius: f64,
330
- eps_in: f64,
331
- eps_out: f64,
332
- ) -> DielectricMaps {
340
+ ) -> Vec<bool> {
333
341
  let nx = grid.dims[0];
334
342
  let ny = grid.dims[1];
335
343
  let nz = grid.dims[2];
336
344
  let n = nx * ny * nz;
337
345
 
338
- // First, compute per-node inside/outside flag
339
- let inside: Vec<bool> = (0..n)
346
+ if probe_radius <= 0.0 {
347
+ // No probe: SES = VDW surface
348
+ return (0..n)
349
+ .into_par_iter()
350
+ .map(|idx| {
351
+ let iz = idx / (nx * ny);
352
+ let iy = (idx % (nx * ny)) / nx;
353
+ let ix = idx % nx;
354
+ let pt = grid.point(ix, iy, iz);
355
+ point_inside_molecule(&pt, coords, radii, 0.0)
356
+ })
357
+ .collect();
358
+ }
359
+
360
+ // Step 1: Compute inside_sas (SAS = union of spheres with r + probe).
361
+ // Outside SAS = valid probe-center positions (probe fits without overlap).
362
+ let inside_sas: Vec<bool> = (0..n)
340
363
  .into_par_iter()
341
364
  .map(|idx| {
342
365
  let iz = idx / (nx * ny);
@@ -347,14 +370,239 @@ pub fn assign_dielectrics(
347
370
  })
348
371
  .collect();
349
372
 
350
- let eps_for = |a: bool, b: bool| -> f64 {
351
- match (a, b) {
352
- (true, true) => eps_in,
353
- (false, false) => eps_out,
354
- _ => 2.0 * eps_in * eps_out / (eps_in + eps_out), // harmonic mean
373
+ // Step 2: Flood-fill from boundary through outside_sas to find the
374
+ // connected exterior (probe centers reachable from the bulk).
375
+ let mut connected_exterior = vec![false; n];
376
+ let mut queue = VecDeque::new();
377
+
378
+ // Seed: boundary points that are outside SAS
379
+ for iz in 0..nz {
380
+ for iy in 0..ny {
381
+ for ix in 0..nx {
382
+ let is_boundary =
383
+ ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1;
384
+ if is_boundary {
385
+ let idx = grid.index(ix, iy, iz);
386
+ if !inside_sas[idx] {
387
+ connected_exterior[idx] = true;
388
+ queue.push_back(idx);
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ // BFS through outside_sas
396
+ while let Some(idx) = queue.pop_front() {
397
+ let iz = idx / (nx * ny);
398
+ let iy = (idx % (nx * ny)) / nx;
399
+ let ix = idx % nx;
400
+
401
+ let neighbors: [Option<usize>; 6] = [
402
+ if ix > 0 {
403
+ Some(grid.index(ix - 1, iy, iz))
404
+ } else {
405
+ None
406
+ },
407
+ if ix + 1 < nx {
408
+ Some(grid.index(ix + 1, iy, iz))
409
+ } else {
410
+ None
411
+ },
412
+ if iy > 0 {
413
+ Some(grid.index(ix, iy - 1, iz))
414
+ } else {
415
+ None
416
+ },
417
+ if iy + 1 < ny {
418
+ Some(grid.index(ix, iy + 1, iz))
419
+ } else {
420
+ None
421
+ },
422
+ if iz > 0 {
423
+ Some(grid.index(ix, iy, iz - 1))
424
+ } else {
425
+ None
426
+ },
427
+ if iz + 1 < nz {
428
+ Some(grid.index(ix, iy, iz + 1))
429
+ } else {
430
+ None
431
+ },
432
+ ];
433
+
434
+ for ni in neighbors.into_iter().flatten() {
435
+ if !connected_exterior[ni] && !inside_sas[ni] {
436
+ connected_exterior[ni] = true;
437
+ queue.push_back(ni);
438
+ }
355
439
  }
440
+ }
441
+
442
+ // Step 3: Dilate connected exterior by probe_radius.
443
+ // A point is outside SES if any connected_exterior point is within probe_radius.
444
+ let probe_sq = probe_radius * probe_radius;
445
+ let rx = (probe_radius / grid.spacing[0]).ceil() as isize;
446
+ let ry = (probe_radius / grid.spacing[1]).ceil() as isize;
447
+ let rz = (probe_radius / grid.spacing[2]).ceil() as isize;
448
+
449
+ (0..n)
450
+ .into_par_iter()
451
+ .map(|idx| {
452
+ let iz = (idx / (nx * ny)) as isize;
453
+ let iy = ((idx % (nx * ny)) / nx) as isize;
454
+ let ix = (idx % nx) as isize;
455
+ let pt = grid.point(ix as usize, iy as usize, iz as usize);
456
+
457
+ for dz in -rz..=rz {
458
+ let jz = iz + dz;
459
+ if jz < 0 || jz >= nz as isize {
460
+ continue;
461
+ }
462
+ for dy in -ry..=ry {
463
+ let jy = iy + dy;
464
+ if jy < 0 || jy >= ny as isize {
465
+ continue;
466
+ }
467
+ for dx in -rx..=rx {
468
+ let jx = ix + dx;
469
+ if jx < 0 || jx >= nx as isize {
470
+ continue;
471
+ }
472
+
473
+ let jidx = grid.index(jx as usize, jy as usize, jz as usize);
474
+ if connected_exterior[jidx] {
475
+ let neighbor_pt = grid.point(jx as usize, jy as usize, jz as usize);
476
+ let ddx = pt[0] - neighbor_pt[0];
477
+ let ddy = pt[1] - neighbor_pt[1];
478
+ let ddz = pt[2] - neighbor_pt[2];
479
+ if ddx * ddx + ddy * ddy + ddz * ddz <= probe_sq {
480
+ return false; // Probe can reach -> outside SES
481
+ }
482
+ }
483
+ }
484
+ }
485
+ }
486
+ true // No probe can reach -> inside SES
487
+ })
488
+ .collect()
489
+ }
490
+
491
+ /// Compute the fraction of an axis-aligned edge segment that lies inside the
492
+ /// molecular surface (union of atom spheres inflated by probe radius).
493
+ ///
494
+ /// The edge runs from `lo` to `hi` along `axis` (0=x, 1=y, 2=z), at fixed
495
+ /// perpendicular coordinates `perp1` and `perp2`.
496
+ ///
497
+ /// Returns a value in [0, 1].
498
+ fn fraction_inside_along_axis(
499
+ lo: f64,
500
+ hi: f64,
501
+ perp1: f64,
502
+ perp2: f64,
503
+ axis: usize,
504
+ coords: &[[f64; 3]],
505
+ radii: &[f64],
506
+ probe_radius: f64,
507
+ ) -> f64 {
508
+ let edge_len = hi - lo;
509
+ if edge_len <= 0.0 {
510
+ return 0.0;
511
+ }
512
+
513
+ // Perpendicular axis indices
514
+ let (a1, a2) = match axis {
515
+ 0 => (1, 2),
516
+ 1 => (0, 2),
517
+ _ => (0, 1),
356
518
  };
357
519
 
520
+ // Collect intervals along the edge that lie inside each atom's sphere
521
+ let mut intervals: Vec<(f64, f64)> = Vec::new();
522
+
523
+ for (c, &r) in coords.iter().zip(radii.iter()) {
524
+ let big_r = r + probe_radius;
525
+ let d1 = perp1 - c[a1];
526
+ let d2 = perp2 - c[a2];
527
+ let rho_sq = big_r * big_r - d1 * d1 - d2 * d2;
528
+ if rho_sq <= 0.0 {
529
+ continue;
530
+ }
531
+ let rho = rho_sq.sqrt();
532
+ let ilo = (c[axis] - rho).max(lo);
533
+ let ihi = (c[axis] + rho).min(hi);
534
+ if ilo < ihi {
535
+ intervals.push((ilo, ihi));
536
+ }
537
+ }
538
+
539
+ if intervals.is_empty() {
540
+ return 0.0;
541
+ }
542
+
543
+ // Sort by interval start
544
+ intervals.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
545
+
546
+ // Merge overlapping intervals and sum total inside length
547
+ let mut total = 0.0;
548
+ let mut cur_lo = intervals[0].0;
549
+ let mut cur_hi = intervals[0].1;
550
+ for &(ilo, ihi) in &intervals[1..] {
551
+ if ilo <= cur_hi {
552
+ cur_hi = cur_hi.max(ihi);
553
+ } else {
554
+ total += cur_hi - cur_lo;
555
+ cur_lo = ilo;
556
+ cur_hi = ihi;
557
+ }
558
+ }
559
+ total += cur_hi - cur_lo;
560
+
561
+ (total / edge_len).clamp(0.0, 1.0)
562
+ }
563
+
564
+ /// Weighted harmonic mean of dielectrics.
565
+ ///
566
+ /// `1/ε = f/ε_in + (1-f)/ε_out` where `f` is the fraction inside.
567
+ /// This is the standard approach used by AMBER and other PB solvers
568
+ /// for face-centered dielectrics at boundary edges.
569
+ #[inline]
570
+ fn weighted_harmonic_eps(f: f64, eps_in: f64, eps_out: f64) -> f64 {
571
+ if f <= 0.0 {
572
+ return eps_out;
573
+ }
574
+ if f >= 1.0 {
575
+ return eps_in;
576
+ }
577
+ 1.0 / (f / eps_in + (1.0 - f) / eps_out)
578
+ }
579
+
580
+ /// Assign face-centered dielectric values based on the Solvent-Excluded Surface.
581
+ ///
582
+ /// Uses the SES (molecular surface) to classify grid points as inside or
583
+ /// outside, then assigns edge dielectrics:
584
+ /// - Both endpoints inside → ε_in
585
+ /// - Both endpoints outside → ε_out
586
+ /// - Mixed → weighted harmonic mean using the fraction of the edge inside
587
+ /// the molecular surface (computed analytically from sphere geometry).
588
+ pub fn assign_dielectrics(
589
+ grid: &PbGrid,
590
+ coords: &[[f64; 3]],
591
+ radii: &[f64],
592
+ probe_radius: f64,
593
+ eps_in: f64,
594
+ eps_out: f64,
595
+ ) -> DielectricMaps {
596
+ let nx = grid.dims[0];
597
+ let ny = grid.dims[1];
598
+ let nz = grid.dims[2];
599
+ let n = nx * ny * nz;
600
+
601
+ // Use the Solvent-Excluded Surface for inside/outside classification.
602
+ // This handles re-entrant regions correctly, unlike the simpler SAS
603
+ // (union of inflated spheres) which over-estimates the cavity size.
604
+ let inside = compute_inside_ses(grid, coords, radii, probe_radius);
605
+
358
606
  let mut eps_x = vec![eps_out; n];
359
607
  let mut eps_y = vec![eps_out; n];
360
608
  let mut eps_z = vec![eps_out; n];
@@ -364,14 +612,70 @@ pub fn assign_dielectrics(
364
612
  for ix in 0..nx {
365
613
  let idx = grid.index(ix, iy, iz);
366
614
  let a = inside[idx];
615
+
616
+ // X-edge: (ix,iy,iz) → (ix+1,iy,iz)
367
617
  if ix + 1 < nx {
368
- eps_x[idx] = eps_for(a, inside[grid.index(ix + 1, iy, iz)]);
618
+ let b = inside[grid.index(ix + 1, iy, iz)];
619
+ if a == b {
620
+ eps_x[idx] = if a { eps_in } else { eps_out };
621
+ } else {
622
+ // Use VDW sphere geometry (probe=0) for the fraction
623
+ // since the SES contact surface is at the VDW boundary.
624
+ let pt = grid.point(ix, iy, iz);
625
+ let f = fraction_inside_along_axis(
626
+ pt[0],
627
+ pt[0] + grid.spacing[0],
628
+ pt[1],
629
+ pt[2],
630
+ 0,
631
+ coords,
632
+ radii,
633
+ 0.0, // VDW radii, no probe inflation
634
+ );
635
+ eps_x[idx] = weighted_harmonic_eps(f, eps_in, eps_out);
636
+ }
369
637
  }
638
+
639
+ // Y-edge: (ix,iy,iz) → (ix,iy+1,iz)
370
640
  if iy + 1 < ny {
371
- eps_y[idx] = eps_for(a, inside[grid.index(ix, iy + 1, iz)]);
641
+ let b = inside[grid.index(ix, iy + 1, iz)];
642
+ if a == b {
643
+ eps_y[idx] = if a { eps_in } else { eps_out };
644
+ } else {
645
+ let pt = grid.point(ix, iy, iz);
646
+ let f = fraction_inside_along_axis(
647
+ pt[1],
648
+ pt[1] + grid.spacing[1],
649
+ pt[0],
650
+ pt[2],
651
+ 1,
652
+ coords,
653
+ radii,
654
+ 0.0,
655
+ );
656
+ eps_y[idx] = weighted_harmonic_eps(f, eps_in, eps_out);
657
+ }
372
658
  }
659
+
660
+ // Z-edge: (ix,iy,iz) → (ix,iy,iz+1)
373
661
  if iz + 1 < nz {
374
- eps_z[idx] = eps_for(a, inside[grid.index(ix, iy, iz + 1)]);
662
+ let b = inside[grid.index(ix, iy, iz + 1)];
663
+ if a == b {
664
+ eps_z[idx] = if a { eps_in } else { eps_out };
665
+ } else {
666
+ let pt = grid.point(ix, iy, iz);
667
+ let f = fraction_inside_along_axis(
668
+ pt[2],
669
+ pt[2] + grid.spacing[2],
670
+ pt[0],
671
+ pt[1],
672
+ 2,
673
+ coords,
674
+ radii,
675
+ 0.0,
676
+ );
677
+ eps_z[idx] = weighted_harmonic_eps(f, eps_in, eps_out);
678
+ }
375
679
  }
376
680
  }
377
681
  }
@@ -385,7 +689,13 @@ pub fn assign_dielectrics(
385
689
  }
386
690
  }
387
691
 
388
- /// Assign κ² map: zero inside ion-exclusion surface, κ²_bulk outside.
692
+ /// Assign κ̄² map: zero inside ion-exclusion surface, ε_s·κ² outside.
693
+ ///
694
+ /// The linearized PBE is: ∇·[ε∇φ] - κ̄²φ = -4π·ec·ρ
695
+ /// where κ̄² = ε_s · κ² (the Debye-Hückel κ² multiplied by the solvent
696
+ /// dielectric). The factor of ε_s arises because the mobile ion charge
697
+ /// density is -2c₀·φ/(kBT) (no ε_s), while κ² = 8π·ec·c₀·N_A/(ε_s·kBT)
698
+ /// already has 1/ε_s from the Debye-Hückel derivation.
389
699
  ///
390
700
  /// The ion-exclusion surface is the molecular surface inflated by the ion radius.
391
701
  pub fn assign_kappa(
@@ -394,12 +704,14 @@ pub fn assign_kappa(
394
704
  radii: &[f64],
395
705
  ion_radius: f64,
396
706
  kappa_bulk: f64,
707
+ solvent_dielectric: f64,
397
708
  ) -> Vec<f64> {
398
709
  let nx = grid.dims[0];
399
710
  let ny = grid.dims[1];
400
711
  let nz = grid.dims[2];
401
712
  let n = nx * ny * nz;
402
- let kappa_sq = kappa_bulk * kappa_bulk;
713
+ // κ̄² = ε_s · κ² is the coefficient in the linearized PBE
714
+ let kappa_bar_sq = solvent_dielectric * kappa_bulk * kappa_bulk;
403
715
 
404
716
  (0..n)
405
717
  .into_par_iter()
@@ -412,7 +724,7 @@ pub fn assign_kappa(
412
724
  if point_inside_molecule(&pt, coords, radii, ion_radius) {
413
725
  0.0
414
726
  } else {
415
- kappa_sq
727
+ kappa_bar_sq
416
728
  }
417
729
  })
418
730
  .collect()
@@ -10,12 +10,15 @@ use crate::pb_grid::{DielectricMaps, PbGrid};
10
10
  use rayon::prelude::*;
11
11
 
12
12
  /// Boundary condition for the PB solve.
13
- #[derive(Debug, Clone, Copy)]
13
+ #[derive(Debug, Clone)]
14
14
  pub enum BoundaryCondition {
15
15
  /// phi = 0 on all boundaries.
16
16
  Zero,
17
17
  /// Debye-Huckel single-sphere approximation on boundaries.
18
18
  DebyeHuckel,
19
+ /// Interpolated from a coarse grid solution (for focusing).
20
+ /// Contains the precomputed boundary values for each boundary grid point.
21
+ Interpolated(Vec<f64>),
19
22
  }
20
23
 
21
24
  /// Set Debye-Huckel boundary conditions on the grid.
@@ -65,6 +68,59 @@ fn set_dh_boundary(
65
68
  }
66
69
  }
67
70
 
71
+ /// Compute interpolated boundary conditions from a coarse grid solution.
72
+ ///
73
+ /// For each boundary point of `fine_grid`, looks up the corresponding
74
+ /// position in the coarse solution via trilinear interpolation.
75
+ pub fn interpolated_boundary(
76
+ fine_grid: &PbGrid,
77
+ coarse_grid: &PbGrid,
78
+ coarse_potential: &[f64],
79
+ ) -> Vec<f64> {
80
+ let nx = fine_grid.dims[0];
81
+ let ny = fine_grid.dims[1];
82
+ let nz = fine_grid.dims[2];
83
+ let n = nx * ny * nz;
84
+ let mut boundary = vec![0.0f64; n];
85
+
86
+ for iz in 0..nz {
87
+ for iy in 0..ny {
88
+ for ix in 0..nx {
89
+ let is_boundary =
90
+ ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1;
91
+ if !is_boundary {
92
+ continue;
93
+ }
94
+ let pt = fine_grid.point(ix, iy, iz);
95
+ let idx = fine_grid.index(ix, iy, iz);
96
+ boundary[idx] = coarse_grid.interpolate_with_data(&pt, coarse_potential);
97
+ }
98
+ }
99
+ }
100
+ boundary
101
+ }
102
+
103
+ /// Set boundary conditions from precomputed interpolated values.
104
+ fn set_interpolated_boundary(potential: &mut [f64], grid: &PbGrid, boundary: &[f64]) {
105
+ let nx = grid.dims[0];
106
+ let ny = grid.dims[1];
107
+ let nz = grid.dims[2];
108
+
109
+ for iz in 0..nz {
110
+ for iy in 0..ny {
111
+ for ix in 0..nx {
112
+ let is_boundary =
113
+ ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1;
114
+ if !is_boundary {
115
+ continue;
116
+ }
117
+ let idx = grid.index(ix, iy, iz);
118
+ potential[idx] = boundary[idx];
119
+ }
120
+ }
121
+ }
122
+ }
123
+
68
124
  /// Estimate the optimal SOR relaxation parameter from grid dimensions.
69
125
  fn estimate_omega(dims: &[usize; 3]) -> f64 {
70
126
  // Spectral radius of Jacobi iteration for 3D Laplacian
@@ -264,11 +320,14 @@ pub fn solve_lpbe(
264
320
  let mut potential = vec![0.0f64; n];
265
321
 
266
322
  // Set boundary conditions
267
- match boundary {
323
+ match &boundary {
268
324
  BoundaryCondition::Zero => {}
269
325
  BoundaryCondition::DebyeHuckel => {
270
326
  set_dh_boundary(&mut potential, grid, coords, charges, kappa_bulk, eps_out);
271
327
  }
328
+ BoundaryCondition::Interpolated(bvals) => {
329
+ set_interpolated_boundary(&mut potential, grid, bvals);
330
+ }
272
331
  }
273
332
 
274
333
  let omega = estimate_omega(&grid.dims);
@@ -703,11 +762,14 @@ pub fn solve_lpbe_multigrid(
703
762
  let mut potential = vec![0.0f64; n];
704
763
 
705
764
  // Set boundary conditions
706
- match boundary {
765
+ match &boundary {
707
766
  BoundaryCondition::Zero => {}
708
767
  BoundaryCondition::DebyeHuckel => {
709
768
  set_dh_boundary(&mut potential, grid, coords, charges, kappa_bulk, eps_out);
710
769
  }
770
+ BoundaryCondition::Interpolated(bvals) => {
771
+ set_interpolated_boundary(&mut potential, grid, bvals);
772
+ }
711
773
  }
712
774
 
713
775
  let min_dim = grid.dims.iter().copied().min().unwrap_or(0);
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rust_simulation_tools"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Fast algorithms for MD trajectories"
9
9
  readme = "README.md"
10
10
  authors = [