Skip to main content

dynamics/solvent/
init.rs

1#![allow(clippy::excessive_precision)]
2
3//! Code for initializing solvent molecules, including assigning quantity, initial positions, and
4//! velocities. Set up to meet density, pressure, and or temperature targets. Not specific to the
5//! solvent model used.
6//!
7//! This involves creating, saving and loading templates, and generating water molecules given a template,
8//! sim box, and solute.
9
10use std::{f32::consts::TAU, fs, io, path::Path, time::Instant};
11
12use bincode::{Decode, Encode};
13use bio_files::gromacs;
14use lin_alg::{
15    f32::{Mat3 as Mat3F32, Quaternion, Vec3},
16    f64::{Quaternion as QuaternionF64, Vec3 as Vec3F64},
17};
18use rand::{Rng, distr::Uniform, rngs::ThreadRng};
19use rand_distr::{Distribution, Normal};
20
21use crate::{
22    AtomDynamics, ComputationDevice, MdState, MolDynamics, NATIVE_TO_KCAL,
23    barostat::SimBox,
24    partial_charge_inference::{files::load_from_bytes_bincode, save},
25    sa_surface,
26    solvent::WaterMol,
27    thermostat::{GAS_CONST_R, KB_A2_PS2_PER_K_PER_AMU},
28};
29
30// 0.997 g cm⁻³ is a good default density for biological pressures. We use this for initializing
31// and maintaining the solvent density and molecule count.
32const WATER_DENSITY: f32 = 0.997;
33
34// g / mol (or AMU per molecule)
35// This is similar to the Amber H and O masses we used summed, and could be explained
36// by precision limits. We use it for generating atoms based on mass density.
37const MASS_WATER: f32 = 18.015_28;
38
39// Avogadro's constant. mol^-1.
40const N_A: f32 = 6.022_140_76e23;
41
42// This is ~0.0333 mol/ų
43// Multiplying this by volume in Angstrom^3 gives us AMU g cm^-3 Å^3 mol^-1
44const WATER_MOLS_PER_VOL: f32 = WATER_DENSITY * N_A / (MASS_WATER * 1.0e24);
45
46// Don't generate solvent molecules that are too close to other atoms.
47// Vdw contact distance between solvent molecules and organic molecules is roughly 3.5 Å.
48// todo: Hmm. We could get lower, but there's some risk of an H atom being too close,
49// todo and we're currently only measuring solvent o dist to atoms.
50const MIN_NONWATER_DIST: f32 = 1.7;
51const MIN_NONWATER_DIST_SQ: f32 = MIN_NONWATER_DIST * MIN_NONWATER_DIST;
52
53// Direct O-O overlap check — prevents truly coincident molecules.
54const MIN_WATER_O_O_DIST: f32 = 1.7;
55const MIN_WATER_O_O_DIST_SQ: f32 = MIN_WATER_O_O_DIST * MIN_WATER_O_O_DIST;
56
57// PBC-boundary exclusion distance.
58// When a smaller box is filled from a larger template (e.g. 30 Å from a 60 Å template),
59// molecules near opposite faces become PBC neighbours even though they were ~30 Å apart in
60// the template and were never equilibrated at that short distance.  These pairs can slip
61// through the 1.7 Å check with PBC distances of 2.0–2.8 Å, where LJ energy is 6–74 kcal/mol
62// per pair — enough to push pressure into the tens-of-thousands-of-bar range.
63// 2.8 Å is just below the first RDF peak (~2.82 Å).  We only apply this stricter threshold
64// when PBC wrapping actually shortens the distance (i.e. `min_image_dist < direct_dist`),
65// so interior template molecules at their natural 2.5–2.8 Å first-shell distances are
66// accepted while un-equilibrated cross-boundary pairs are rejected.
67const PBC_MIN_WATER_O_O_DIST: f32 = 2.8;
68const PBC_MIN_WATER_O_O_DIST_SQ: f32 = PBC_MIN_WATER_O_O_DIST * PBC_MIN_WATER_O_O_DIST;
69
70// Higher is more accurate, but slower. After hydrogen bond networks are settled, higher doensn't
71// improve things. Note that we initialize from a pre-equilibrated template, so we shouldn't
72// need many effects. This mainly deals with template tiling effects, and solvent-solute conflicts.
73const NUM_EQUILIBRATION_STEPS: usize = 200;
74// Like in our normal setup with constraint H, 0.002ps may be the safe upper bound.
75// We seem to get better settling results with a low dt.
76const DT_EQUILIBRATION: f32 = 0.0005;
77
78// We use this externally, for example, when passing it to GROMACS in Molchanica.
79pub const WATER_TEMPLATE_60A: &[u8] =
80    include_bytes!("../../param_data/water_60A.water_init_template");
81
82/// We store pre-equilibrated solvent molecules in a template, and use it to initialize solvent for a simulation.
83/// This keeps the equilibration steps relatively low. Note that edge effects from tiling will require
84/// equilibration, as well as adjusting a template for the runtime temperature target.
85///
86/// Struct-of-array layout. (Corresponding indices)
87/// Public so it can be created by the application after a run.
88///
89/// 108 bytes/mol. Size on disk/mem: for a 60Å side len: ~780kb. (Hmm: We're getting a bit less)
90/// 80Å/side: 1.20Mb.
91#[derive(Encode, Decode)]
92pub struct WaterInitTemplate {
93    // velocity is o velocity, instead of 3 separate velocities
94    o_posits: Vec<Vec3>,
95    h0_posits: Vec<Vec3>,
96    h1_posits: Vec<Vec3>,
97    o_velocities: Vec<Vec3>,
98    h0_velocities: Vec<Vec3>,
99    h1_velocities: Vec<Vec3>,
100    // todo: Cache these, or infer?
101    /// This must correspond to the positions. Cached.
102    bounds: (Vec3, Vec3),
103}
104
105impl WaterInitTemplate {
106    /// Load a previously-saved template from a file path.
107    pub fn load(path: &Path) -> io::Result<Self> {
108        let bytes = fs::read(path)?;
109        load_from_bytes_bincode(&bytes)
110    }
111
112    pub fn from_bytes(bytes: &[u8]) -> io::Result<Self> {
113        load_from_bytes_bincode(bytes)
114    }
115
116    /// Construct from the current state, and save to file.
117    /// Call this explicitly. (todo: Determine a formal or informal approach)
118    pub fn create_and_save(
119        water: &[WaterMol],
120        bounds: (Vec3, Vec3),
121        path: &Path,
122    ) -> io::Result<()> {
123        let n = water.len();
124
125        let mut o_posits = Vec::with_capacity(n);
126        let mut h0_posits = Vec::with_capacity(n);
127        let mut h1_posits = Vec::with_capacity(n);
128
129        let mut o_velocities = Vec::with_capacity(n);
130        let mut h0_velocities = Vec::with_capacity(n);
131        let mut h1_velocities = Vec::with_capacity(n);
132
133        // let (mut min, mut max) = (Vec3::splat(f32::INFINITY), Vec3::splat(f32::NEG_INFINITY));
134
135        // Sort solvent by position so that it iterates out from the center. This makes initialization
136        // easier for cases where this template is larger than the target sim box.
137        let water = {
138            let ctr = (bounds.1 + bounds.0) / 2.;
139
140            let mut w = water.to_vec();
141            w.sort_by(|a, b| {
142                let da = (a.o.posit - ctr).magnitude_squared();
143                let db = (b.o.posit - ctr).magnitude_squared();
144                da.total_cmp(&db)
145            });
146
147            w
148        };
149
150        for mol in water {
151            o_posits.push(mol.o.posit);
152            h0_posits.push(mol.h0.posit);
153            h1_posits.push(mol.h1.posit);
154
155            o_velocities.push(mol.o.vel);
156            h0_velocities.push(mol.h0.vel);
157            h1_velocities.push(mol.h1.vel);
158
159            // min = min.min(mol.o.posit);
160            // max = max.max(mol.o.posit);
161        }
162
163        let result = Self {
164            o_posits,
165            h0_posits,
166            h1_posits,
167            o_velocities,
168            h0_velocities,
169            h1_velocities,
170            bounds,
171        };
172
173        save(path, &result)
174    }
175
176    // todo: Identical structs; we could consolidate.
177    /// Note: `gmx solvate`` handles tiling, centering, and solute deconfliction; we can
178    /// send it the raw template.
179    pub fn to_gromacs(&self) -> gromacs::solvate::WaterInitTemplate {
180        gromacs::solvate::WaterInitTemplate {
181            o_posits: self.o_posits.clone(),
182            h0_posits: self.h0_posits.clone(),
183            h1_posits: self.h1_posits.clone(),
184            o_velocities: self.o_velocities.clone(),
185            h0_velocities: self.h0_velocities.clone(),
186            h1_velocities: self.h1_velocities.clone(),
187            bounds: self.bounds,
188        }
189    }
190}
191
192/// Determine the number of solvent molecules to add, based on box size and solute.
193fn n_water_mols(cell: &SimBox, solute_atoms: &[AtomDynamics]) -> usize {
194    let cell_volume = cell.volume();
195    let mol_volume = sa_surface::vol_take_up_by_atoms(solute_atoms);
196    let free_vol = cell_volume - mol_volume;
197
198    let dims = format!(
199        "{}:.2 x {:.2} x {:.2}",
200        (cell.bounds_high.x - cell.bounds_low.x).abs(),
201        (cell.bounds_high.y - cell.bounds_low.y).abs(),
202        (cell.bounds_high.z - cell.bounds_low.z).abs()
203    );
204
205    println!(
206        "Solvent-free vol: {:.2} Cell vol: {:.2} (ų / 1,000). Dims: {dims} Å",
207        free_vol / 1_000.,
208        cell_volume / 1_000.
209    );
210
211    // Estimate free volume & n_mols from it
212    (WATER_MOLS_PER_VOL * free_vol).round() as usize
213}
214
215/// Create solvent molecules from a template, tiling it as many times as needed to fill the cell.
216/// Works for any cell size: smaller than, equal to, or larger than the template.
217///
218/// The template is always centered on the cell. For cells smaller than the template only tile
219/// (0,0,0) contributes; for larger cells neighboring tiles fill in the rest.
220/// Water-solvent conflict detection uses min-image distances so molecules are never placed too
221/// close to a PBC image of an already-placed molecule.
222///
223/// `template_override`: if provided, use this template instead of the built-in 60 Å one.
224pub fn make_water_mols(
225    cell: &SimBox,
226    atoms: &[AtomDynamics],
227    specify_num_water: Option<usize>,
228    template_override: Option<&WaterInitTemplate>,
229    // When true, skip the PBC-boundary proximity check (the 2.8 Å cross-boundary filter).
230    // Only the hard-overlap 1.7 Å direct-distance check remains.  Use this when generating
231    // a template at the correct equilibrium density: the ~88 boundary molecules that the PBC
232    // filter would reject are acceptable starting points; the MD equilibration run will push
233    // them to their natural first-shell distances.
234    skip_pbc_filter: bool,
235) -> Vec<WaterMol> {
236    println!("Initializing solvent molecules...");
237    let start = Instant::now();
238
239    let default_template;
240
241    let template: &WaterInitTemplate = match template_override {
242        Some(t) => t,
243        None => {
244            default_template = load_from_bytes_bincode(WATER_TEMPLATE_60A).unwrap();
245            &default_template
246        }
247    };
248
249    let n_mols = specify_num_water.unwrap_or_else(|| n_water_mols(cell, atoms));
250    let mut result = Vec::with_capacity(n_mols);
251
252    if n_mols == 0 {
253        println!("Complete in {} ms.", start.elapsed().as_millis());
254        return result;
255    }
256
257    let atom_posits: Vec<_> = atoms.iter().map(|a| a.posit).collect();
258
259    let template_size = template.bounds.1 - template.bounds.0;
260    let template_ctr = (template.bounds.0 + template.bounds.1) / 2.;
261    let cell_ctr = (cell.bounds_low + cell.bounds_high) / 2.;
262
263    // Align tile (0,0,0) center to the cell center.
264    let base_offset = cell_ctr - template_ctr;
265
266    // Number of half-tiles needed to cover the cell in each direction (+1 for safety).
267    let cell_size = cell.bounds_high - cell.bounds_low;
268    let half_x = (cell_size.x / (2.0 * template_size.x)).ceil() as i32 + 1;
269    let half_y = (cell_size.y / (2.0 * template_size.y)).ceil() as i32 + 1;
270    let half_z = (cell_size.z / (2.0 * template_size.z)).ceil() as i32 + 1;
271
272    let mut loops_used = 0;
273
274    'tiles: for ix in -half_x..=half_x {
275        for iy in -half_y..=half_y {
276            for iz in -half_z..=half_z {
277                let tile_offset = base_offset
278                    + Vec3::new(
279                        ix as f32 * template_size.x,
280                        iy as f32 * template_size.y,
281                        iz as f32 * template_size.z,
282                    );
283
284                'mol: for i in 0..template.o_posits.len() {
285                    let o_posit = template.o_posits[i] + tile_offset;
286                    let h0_posit = template.h0_posits[i] + tile_offset;
287                    let h1_posit = template.h1_posits[i] + tile_offset;
288
289                    loops_used += 1;
290
291                    if !cell.contains(o_posit) {
292                        continue;
293                    }
294
295                    // Conflict with solute atoms.
296                    for &atom_p in &atom_posits {
297                        if (atom_p - o_posit).magnitude_squared() < MIN_NONWATER_DIST_SQ {
298                            continue 'mol;
299                        }
300                    }
301
302                    // Conflict with already-placed solvent.
303                    // Two-threshold check:
304                    //   1. Direct distance < 1.7 Å: hard overlap regardless of PBC.
305                    //   2. PBC-wrapped distance < 2.8 Å *and* wrapping shortened the distance:
306                    //      these are cross-boundary pairs from the template that were never
307                    //      equilibrated as PBC neighbours in this (smaller) cell.
308                    //      Interior template molecules at their natural 2.5–2.8 Å first-shell
309                    //      distances are not affected (min_image == direct for them).
310                    for w in &result {
311                        let diff = w.o.posit - o_posit;
312                        let direct_sq = diff.magnitude_squared();
313                        if direct_sq < MIN_WATER_O_O_DIST_SQ {
314                            continue 'mol;
315                        }
316                        let min_image_sq = cell.min_image(diff).magnitude_squared();
317                        // Always reject PBC hard overlaps (PBC distance < 1.7 Å) even when
318                        // skip_pbc_filter is true, to prevent catastrophic initial forces.
319                        if min_image_sq < MIN_WATER_O_O_DIST_SQ {
320                            continue 'mol;
321                        }
322                        if !skip_pbc_filter {
323                            if min_image_sq < PBC_MIN_WATER_O_O_DIST_SQ && min_image_sq < direct_sq
324                            {
325                                continue 'mol;
326                            }
327                        }
328                    }
329
330                    let mut mol = WaterMol::new(
331                        Vec3::new_zero(),
332                        Vec3::new_zero(),
333                        Quaternion::new_identity(),
334                    );
335                    mol.o.posit = o_posit;
336                    mol.h0.posit = h0_posit;
337                    mol.h1.posit = h1_posit;
338                    mol.o.vel = template.o_velocities[i];
339                    mol.h0.vel = template.h0_velocities[i];
340                    mol.h1.vel = template.h1_velocities[i];
341
342                    result.push(mol);
343
344                    if result.len() == n_mols {
345                        break 'tiles;
346                    }
347                }
348            }
349        }
350    }
351
352    let elapsed = start.elapsed().as_millis();
353    println!(
354        "Added {} / {n_mols} solvent mols in {elapsed} ms. Used {loops_used} loops",
355        result.len()
356    );
357
358    result
359}
360
361/// Pack copies of each custom solvent molecule into the simulation box, deconflicting with
362/// already-placed atoms (e.g. the solute).  Returns one `MolDynamics` per copy, each with
363/// `atom_posits` set to its chosen world-space positions.
364///
365/// Uses a greedy cubic-grid strategy with random-rotation search, identical in spirit to
366/// `add_copies` in the molchanica layer.  Soft overlaps (> 1.4 Å apart) are accepted and
367/// resolved later by the energy minimiser; hard overlaps are caught by `check_for_overlaps_oob`.
368///
369/// Only supports `SimBoxInit::Fixed` boxes — the caller is responsible for ensuring this.
370pub(crate) fn pack_custom_solvent(
371    bounds_low: Vec3,
372    bounds_high: Vec3,
373    existing_posits: &[Vec3F64], // declared positions of already-placed mols (e.g. solute)
374    mols_solvent: &[(MolDynamics, usize)],
375) -> Vec<MolDynamics> {
376    // Below this squared distance between atoms we log a soft-overlap warning.
377    // Energy minimisation resolves overlaps above the hard cutoff used in check_for_overlaps_oob.
378    const MIN_ATOM_DIST_SQ: f64 = 1.4 * 1.4; // Ų
379    // Keep every atom at least this far from each box face.
380    const WALL_MARGIN: f64 = 0.6; // Å  (slightly > check_for_overlaps_oob's 0.5 Å limit)
381    const MAX_ROT_ATTEMPTS: usize = 200;
382
383    let mut rng = rand::rng();
384
385    let lo = Vec3F64::new(
386        bounds_low.x as f64,
387        bounds_low.y as f64,
388        bounds_low.z as f64,
389    );
390    let hi = Vec3F64::new(
391        bounds_high.x as f64,
392        bounds_high.y as f64,
393        bounds_high.z as f64,
394    );
395    let box_size = hi - lo;
396    let box_ctr = (lo + hi) * 0.5;
397
398    // Grows as copies are committed; starts with the solute atom positions.
399    let mut placed_posits: Vec<Vec3F64> = existing_posits.to_vec();
400
401    let mut result: Vec<MolDynamics> = Vec::new();
402
403    for (mol, count) in mols_solvent {
404        let count = *count;
405        if count == 0 {
406            continue;
407        }
408
409        // Template positions in world space; prefer atom_posits override.
410        let template_world: Vec<Vec3F64> = if let Some(ap) = &mol.atom_posits {
411            ap.clone()
412        } else {
413            mol.atoms.iter().map(|a| a.posit).collect()
414        };
415
416        let n_atoms = template_world.len();
417        if n_atoms == 0 {
418            continue;
419        }
420
421        // Centroid and centroid-relative locals.
422        let centroid = template_world
423            .iter()
424            .fold(Vec3F64::new(0., 0., 0.), |s, &p| s + p)
425            * (1.0 / n_atoms as f64);
426        let local: Vec<Vec3F64> = template_world.iter().map(|&p| p - centroid).collect();
427        let bounding_r: f64 = local.iter().map(|p| p.magnitude()).fold(0.0_f64, f64::max);
428
429        // Spatial early-reject radius: only check placed atoms within this of a candidate centroid.
430        let search_sq = (bounding_r * 2.0 + 2.0).powi(2);
431
432        // Every copy's centroid must be ≥ bounding_r + WALL_MARGIN from each wall face so that
433        // even the molecule's furthest atom stays within the per-atom wall margin.
434        let safe_margin = bounding_r + WALL_MARGIN;
435        let inner_lo = lo + Vec3F64::new(safe_margin, safe_margin, safe_margin);
436        let inner_hi = hi - Vec3F64::new(safe_margin, safe_margin, safe_margin);
437
438        if inner_lo.x >= inner_hi.x || inner_lo.y >= inner_hi.y || inner_lo.z >= inner_hi.z {
439            eprintln!(
440                "pack_custom_solvent: box too small for molecule \
441                 (bounding_r={:.1} Å, need >{:.1} Å per side); skipping {} copies.",
442                bounding_r,
443                2.0 * safe_margin,
444                count
445            );
446            continue;
447        }
448
449        let inner_size = inner_hi - inner_lo;
450        // Scale n up from ∛count so that valid grid cells (those within inner_lo..inner_hi)
451        // number at least `count`.  Use the tightest dimension as the conservative factor.
452        let naive_n = (count as f64).cbrt().ceil() as usize;
453        let scale = (box_size.x / inner_size.x)
454            .max(box_size.y / inner_size.y)
455            .max(box_size.z / inner_size.z);
456        let n = ((naive_n as f64 * scale).ceil() as usize).max(3);
457        let (sx, sy, sz) = (
458            box_size.x / n as f64,
459            box_size.y / n as f64,
460            box_size.z / n as f64,
461        );
462        // Box-centred half-widths for the per-atom wall check.
463        let (hx, hy, hz) = (
464            box_size.x * 0.5 - WALL_MARGIN,
465            box_size.y * 0.5 - WALL_MARGIN,
466            box_size.z * 0.5 - WALL_MARGIN,
467        );
468
469        // Grid cell centres restricted to the safe inner region.
470        let mut grid: Vec<Vec3F64> = (0..n)
471            .flat_map(|ix| {
472                (0..n).flat_map(move |iy| {
473                    (0..n).map(move |iz| {
474                        Vec3F64::new(
475                            lo.x + (ix as f64 + 0.5) * sx,
476                            lo.y + (iy as f64 + 0.5) * sy,
477                            lo.z + (iz as f64 + 0.5) * sz,
478                        )
479                    })
480                })
481            })
482            .filter(|c| {
483                c.x >= inner_lo.x
484                    && c.x <= inner_hi.x
485                    && c.y >= inner_lo.y
486                    && c.y <= inner_hi.y
487                    && c.z >= inner_lo.z
488                    && c.z <= inner_hi.z
489            })
490            .collect();
491
492        for copy_i in 0..count {
493            // Greedy: pick the cell whose centroid is furthest from all placed atoms.
494            let best_cell_idx = if placed_posits.is_empty() {
495                0
496            } else {
497                grid.iter()
498                    .enumerate()
499                    .map(|(i, &cell_ctr)| {
500                        let min_dsq = placed_posits
501                            .iter()
502                            .map(|&p| (cell_ctr - p).magnitude_squared())
503                            .fold(f64::MAX, f64::min);
504                        (i, min_dsq)
505                    })
506                    .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
507                    .map(|(i, _)| i)
508                    .unwrap_or(0)
509            };
510
511            let world_ctr = grid.remove(best_cell_idx);
512
513            let mut best_min_sq = f64::NEG_INFINITY;
514            let mut best_posits: Vec<Vec3F64> = Vec::new();
515
516            for _ in 0..MAX_ROT_ATTEMPTS {
517                let (w, x, y, z): (f64, f64, f64, f64) =
518                    (rng.random(), rng.random(), rng.random(), rng.random());
519                let rot = QuaternionF64::new(w, x, y, z).to_normalized();
520
521                let new_posits: Vec<Vec3F64> = local
522                    .iter()
523                    .map(|&l| rot.rotate_vec(l) + world_ctr)
524                    .collect();
525
526                // Wall check in box-centred coordinates.
527                if !new_posits.iter().all(|p| {
528                    let dp = *p - box_ctr;
529                    dp.x.abs() <= hx && dp.y.abs() <= hy && dp.z.abs() <= hz
530                }) {
531                    continue;
532                }
533
534                // Atom-level clash check against placed atoms.
535                let mut min_sq = f64::MAX;
536                'check: for &np in &new_posits {
537                    for &pp in &placed_posits {
538                        if (pp - world_ctr).magnitude_squared() > search_sq {
539                            continue;
540                        }
541                        let dsq = (np - pp).magnitude_squared();
542                        if dsq < min_sq {
543                            min_sq = dsq;
544                            if min_sq < MIN_ATOM_DIST_SQ {
545                                break 'check;
546                            }
547                        }
548                    }
549                }
550
551                if min_sq > best_min_sq {
552                    best_min_sq = min_sq;
553                    best_posits = new_posits;
554                }
555                if best_min_sq >= MIN_ATOM_DIST_SQ {
556                    break; // Clean placement found.
557                }
558            }
559
560            // Fallback if every rotation attempt failed the wall check.
561            if best_posits.is_empty() {
562                best_posits = local.iter().map(|&l| l + world_ctr).collect();
563            }
564
565            if best_min_sq < MIN_ATOM_DIST_SQ {
566                eprintln!(
567                    "pack_custom_solvent: copy {copy_i}: best min atom dist {:.2} Å — \
568                     placing with soft overlap (energy minimiser will resolve).",
569                    best_min_sq.max(0.0).sqrt()
570                );
571            }
572
573            placed_posits.extend_from_slice(&best_posits);
574
575            let mut mol_copy = mol.clone();
576            mol_copy.atom_posits = Some(best_posits);
577            result.push(mol_copy);
578
579            if grid.is_empty() && copy_i + 1 < count {
580                eprintln!(
581                    "pack_custom_solvent: grid cells exhausted after {} / {} copies; \
582                     box may be too small for this many solvent molecules.",
583                    copy_i + 1,
584                    count
585                );
586                break;
587            }
588        }
589    }
590
591    result
592}
593
594#[allow(unused)]
595/// Creates a regular lattice of water molecules. We use this as the first part of creating
596/// a solvent template. Use this,  run a sim with thermostat and barostat, then store the result
597/// in a `WaterInitTemplate`. We can save and load this to disk as binary, or in `.gro` format.
598///
599/// Generate solvent molecules to meet a temperature target, using standard density assumptions.
600/// We deconflict with (solute) atoms in the simulation, and base the number of molecules to add
601/// on the free space, not the total cell volume.
602///
603/// Process:
604/// - Compute the number of molecules to add
605/// - Add them on a regular grid with random orientations, and velocities in a random distribution
606///   that matches the target temperature. Move molecules to the edge that are too close to
607///   solute atoms.
608///
609/// Note: If we're able to place most, but not all waters, the barostat should adjust the sim box size
610/// to account for the lower-than-specific pressure.
611pub fn make_water_mols_grid(
612    cell: &SimBox,
613    temperature_tgt: f32,
614    zero_com_drift: bool,
615) -> Vec<WaterMol> {
616    println!("Initializing a solvent grid, as part of template preparation...");
617    // Initialize an RNG for orientations.
618    let mut rng = rand::rng();
619    let distro = Uniform::<f32>::new(0.0, 1.0).unwrap();
620
621    let n_mols = n_water_mols(cell, &[]);
622
623    let mut result: Vec<WaterMol> = Vec::with_capacity(n_mols);
624
625    // Initialize the correct number of solvent molecules on a uniform grid. We ignore the solute for
626    let lx = cell.bounds_high.x - cell.bounds_low.x;
627    let ly = cell.bounds_high.y - cell.bounds_low.y;
628    let lz = cell.bounds_high.z - cell.bounds_low.z;
629
630    let base = (n_mols as f32).cbrt().round().max(1.0) as usize;
631    let n_x = base;
632    let n_y = base;
633    let n_z = n_mols.div_ceil(n_x * n_y);
634
635    let spacing_x = lx / n_x as f32;
636    let spacing_y = ly / n_y as f32;
637    let spacing_z = lz / n_z as f32;
638
639    // Prevents unbounded looping. A higher value means we're more likely to succed,
640    // but the run time could be higher.
641    let fault_ratio = 3;
642
643    let mut num_added = 0;
644    let mut loops_used = 0;
645
646    'outer: for i in 0..n_mols * fault_ratio {
647        let a = i % n_x;
648        let b = (i / n_x) % n_y;
649        let c = (i / (n_x * n_y)) % n_z;
650
651        let posit = Vec3::new(
652            cell.bounds_low.x + (a as f32 + 0.5) * spacing_x,
653            cell.bounds_low.y + (b as f32 + 0.5) * spacing_y,
654            cell.bounds_low.z + (c as f32 + 0.5) * spacing_z,
655        );
656
657        // Check for an overlap with existing solvent molecules.
658        for w in &result {
659            let dist_sq = (w.o.posit - posit).magnitude_squared();
660            if dist_sq < MIN_WATER_O_O_DIST_SQ {
661                loops_used += 1;
662                continue 'outer;
663            }
664        }
665
666        result.push(WaterMol::new(
667            posit,
668            Vec3::new_zero(),
669            random_quaternion(&mut rng, distro),
670        ));
671        num_added += 1;
672
673        if num_added == n_mols {
674            break;
675        }
676        loops_used += 1;
677    }
678
679    // Set velocities consistent with the temperature target.
680    init_velocities(&mut result, temperature_tgt, zero_com_drift, &mut rng);
681
682    println!(
683        "Added {} / {n_mols} solvent mols. Used {loops_used} loops",
684        result.len()
685    );
686    result
687}
688
689/// We use this as part of our water template generation.
690///
691/// Note: This sets a reasonable default, but our thermostat, applied notably during
692/// our initial solvent simulation, determines the actual temperature set at proper sim init.
693/// Note: We've deprecated this in favor of velocities pre-initialized in the template.
694fn init_velocities(
695    mols: &mut [WaterMol],
696    t_target: f32,
697    zero_com_drift: bool,
698    rng: &mut ThreadRng,
699) {
700    let kT = KB_A2_PS2_PER_K_PER_AMU * t_target;
701
702    for m in mols.iter_mut() {
703        // COM and relative positions
704        let (r_com, m_tot) = {
705            let mut r = Vec3::new_zero();
706            let mut m_tot = 0.0;
707            for a in [&m.o, &m.h0, &m.h1] {
708                r += a.posit * a.mass;
709                m_tot += a.mass;
710            }
711            (r / m_tot, m_tot)
712        };
713
714        let r_0 = m.o.posit - r_com;
715        let r_h0 = m.h0.posit - r_com;
716        let r_h1 = m.h1.posit - r_com;
717
718        // Sample COM velocity
719        let sigma_v = (kT / m_tot).sqrt();
720        let n = Normal::new(0.0, sigma_v).unwrap();
721        let v_com = Vec3::new(n.sample(rng), n.sample(rng), n.sample(rng));
722
723        // Inertia tensor about COM (world frame)
724        // Build as arrays (your code)
725        let inertia = |r: Vec3, mass: f32| {
726            let r2 = r.dot(r);
727            [
728                [
729                    mass * (r2 - r.x * r.x),
730                    -mass * r.x * r.y,
731                    -mass * r.x * r.z,
732                ],
733                [
734                    -mass * r.y * r.x,
735                    mass * (r2 - r.y * r.y),
736                    -mass * r.y * r.z,
737                ],
738                [
739                    -mass * r.z * r.x,
740                    -mass * r.z * r.y,
741                    mass * (r2 - r.z * r.z),
742                ],
743            ]
744        };
745        let mut I_arr = inertia(r_0, m.o.mass);
746        let add_I = |I: &mut [[f32; 3]; 3], J: [[f32; 3]; 3]| {
747            for i in 0..3 {
748                for j in 0..3 {
749                    I[i][j] += J[i][j];
750                }
751            }
752        };
753        add_I(&mut I_arr, inertia(r_h0, m.h0.mass));
754        add_I(&mut I_arr, inertia(r_h1, m.h1.mass));
755
756        let I = Mat3F32::from_arr(I_arr);
757
758        // Diagonalize and solve with the Mat3 methods
759        let (eigvecs, eigvals) = I.eigen_vecs_vals();
760        let L_principal = Vec3::new(
761            Normal::new(0.0, (kT * eigvals.x.max(0.0)).sqrt())
762                .unwrap()
763                .sample(rng),
764            Normal::new(0.0, (kT * eigvals.y.max(0.0)).sqrt())
765                .unwrap()
766                .sample(rng),
767            Normal::new(0.0, (kT * eigvals.z.max(0.0)).sqrt())
768                .unwrap()
769                .sample(rng),
770        );
771        let L_world = eigvecs * L_principal; // assumes Mat3 * Vec3 is implemented
772        let omega = I.solve_system(L_world); // ω = I^{-1} L
773
774        // Set atomic velocities
775        m.o.vel = v_com + omega.cross(r_0);
776        m.h0.vel = v_com + omega.cross(r_h0);
777        m.h1.vel = v_com + omega.cross(r_h1);
778    }
779
780    if zero_com_drift {
781        // Remove global COM drift
782        remove_com_velocity(mols);
783    }
784
785    let (ke_raw, dof) = _kinetic_energy_and_dof(mols, zero_com_drift);
786
787    // current T = 2 KE / (dof * R)
788    let temperature_meas = (2.0 * ke_raw) / (dof as f32 * GAS_CONST_R as f32);
789    let lambda = (t_target / temperature_meas).sqrt();
790
791    for a in atoms_mut(mols) {
792        if a.mass > 0.0 {
793            a.vel *= lambda;
794        }
795    }
796}
797
798/// Calculate kinetic energy in kcal/mol, and DOF for solvent only.
799/// Water is rigid, so 3 DOF per molecule.
800fn _kinetic_energy_and_dof(mols: &[WaterMol], zero_com_drift: bool) -> (f32, usize) {
801    let mut ke = 0.;
802    for w in mols {
803        ke += (w.o.mass * w.o.vel.magnitude_squared()) as f64;
804        ke += (w.h0.mass * w.h0.vel.magnitude_squared()) as f64;
805        ke += (w.h1.mass * w.h1.vel.magnitude_squared()) as f64;
806    }
807
808    let mut dof = mols.len() * 3;
809
810    if zero_com_drift {
811        dof = dof.saturating_sub(3);
812    }
813
814    // Add in the 0.5 factor, and convert from amu • (Å/ps)² to kcal/mol.
815    (ke as f32 * 0.5 * NATIVE_TO_KCAL, dof)
816}
817
818fn atoms_mut(mols: &mut [WaterMol]) -> impl Iterator<Item = &mut AtomDynamics> {
819    mols.iter_mut()
820        .flat_map(|m| [&mut m.o, &mut m.h0, &mut m.h1].into_iter())
821}
822
823#[allow(unused)]
824/// Removes center-of-mass drift. Use in template generation
825fn remove_com_velocity(mols: &mut [WaterMol]) {
826    let mut p = Vec3::new_zero();
827    let mut m_tot = 0.0;
828    for a in atoms_mut(mols) {
829        p += a.vel * a.mass;
830        m_tot += a.mass;
831    }
832
833    let v_com = p / m_tot;
834    for a in atoms_mut(mols) {
835        a.vel -= v_com;
836    }
837}
838
839#[allow(unused)]
840/// Used in template generation
841// todo: It might be nice to have this in lin_alg, although I don't want to add the rand
842// todo dependency to it.
843fn random_quaternion(rng: &mut ThreadRng, distro: Uniform<f32>) -> Quaternion {
844    let (u1, u2, u3) = (rng.sample(distro), rng.sample(distro), rng.sample(distro));
845    let sqrt1_minus_u1 = (1.0 - u1).sqrt();
846    let sqrt_u1 = u1.sqrt();
847    let (theta1, theta2) = (TAU * u2, TAU * u3);
848
849    Quaternion::new(
850        sqrt1_minus_u1 * theta1.sin(),
851        sqrt1_minus_u1 * theta1.cos(),
852        sqrt_u1 * theta2.sin(),
853        sqrt_u1 * theta2.cos(),
854    )
855    .to_normalized()
856}
857
858impl MdState {
859    /// Use this to help initialize solvent molecules to realistic geometry of hydrogen bond networks,
860    /// prior to the first proper simulation step. Runs MD on solvent only.
861    /// Make sure to only run this after state is properly initialized, e.g. towards the end
862    /// of init; not immediately after populating waters.
863    ///
864    /// This will result in an immediate energy bump as solvent positions settle from their grid
865    /// into position. As they settle, the thermostat will bring the velocities down to set
866    /// the target temp. This sim should run long enough to the solvent is stable by the time
867    /// the main sim starts.
868    pub fn md_on_water_only(&mut self, dev: &ComputationDevice) {
869        println!("Initializing solvent H bond networks...");
870        let start = Instant::now();
871
872        // This disables things like snapshot saving, and certain prints.
873        self.solvent_only_sim_at_init = true;
874
875        // Mark all non-solvent atoms as static; keep track of their original state here.
876        let mut static_state = Vec::with_capacity(self.atoms.len());
877        for a in &mut self.atoms {
878            static_state.push(a.static_);
879            a.static_ = true;
880        }
881
882        for _ in 0..NUM_EQUILIBRATION_STEPS {
883            self.step(dev, DT_EQUILIBRATION, None);
884        }
885
886        // Restore the original static state.
887        for (i, a) in self.atoms.iter_mut().enumerate() {
888            a.static_ = static_state[i];
889        }
890
891        self.solvent_only_sim_at_init = false;
892        self.step_count = 0; // Reset.
893
894        let elapsed = start.elapsed().as_millis();
895        println!("Water H bond networks complete in {elapsed} ms");
896    }
897}