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. Populates simulation boxes with solvents, with a PBC assumption.
6//!
7//! This involves creating, saving and loading templates, and generating water molecules given a template,
8//! sim box, and solute.
9
10use std::{fs, io, path::Path, time::Instant};
11
12use bincode::{Decode, Encode};
13use bio_files::{gromacs, gromacs::gro::Gro};
14use lin_alg::f32::{Quaternion, Vec3};
15
16use crate::{
17    AtomDynamics, ComputationDevice, MdState, Solvent,
18    barostat::SimBox,
19    partial_charge_inference::{files::load_from_bytes_bincode, save},
20    sa_surface,
21    solvent::WaterMolOpc,
22};
23// 0.997 g cm⁻³ is a good default density for biological pressures. We use this for initializing
24// and maintaining the solvent density and molecule count.
25const WATER_DENSITY: f32 = 0.997;
26
27// g / mol (or AMU per molecule)
28// This is similar to the Amber H and O masses we used summed, and could be explained
29// by precision limits. We use it for generating atoms based on mass density.
30const MASS_WATER: f32 = 18.015_28;
31
32// Avogadro's constant. mol^-1.
33const N_A: f32 = 6.022_140_76e23;
34
35// This is ~0.0333 mol/ų
36// Multiplying this by volume in Angstrom^3 gives us AMU g cm^-3 Å^3 mol^-1
37const WATER_MOLS_PER_VOL: f32 = WATER_DENSITY * N_A / (MASS_WATER * 1.0e24);
38
39// Don't generate solvent molecules that are too close to other atoms.
40// Vdw contact distance between solvent molecules and organic molecules is roughly 3.5 Å.
41const MIN_NONWATER_DIST: f32 = 1.7;
42const MIN_NONWATER_DIST_SQ: f32 = MIN_NONWATER_DIST * MIN_NONWATER_DIST;
43
44// Direct O-O overlap check — prevents truly coincident molecules.
45const MIN_WATER_O_O_DIST: f32 = 1.7;
46pub(in crate::solvent) const MIN_WATER_O_O_DIST_SQ: f32 = MIN_WATER_O_O_DIST * MIN_WATER_O_O_DIST;
47
48// PBC-boundary exclusion distance.
49// When a smaller box is filled from a larger template (e.g. 30 Å from a 60 Å template),
50// molecules near opposite faces become PBC neighbours even though they were ~30 Å apart in
51// the template and were never equilibrated at that short distance.  These pairs can slip
52// through the 1.7 Å check with PBC distances of 2.0–2.8 Å, where LJ energy is 6–74 kcal/mol
53// per pair — enough to push pressure into the tens-of-thousands-of-bar range.
54// 2.8 Å is just below the first RDF peak (~2.82 Å).  We only apply this stricter threshold
55// when PBC wrapping actually shortens the distance (i.e. `min_image_dist < direct_dist`),
56// so interior template molecules at their natural 2.5–2.8 Å first-shell distances are
57// accepted while un-equilibrated cross-boundary pairs are rejected.
58const PBC_MIN_WATER_O_O_DIST: f32 = 2.8;
59const PBC_MIN_WATER_O_O_DIST_SQ: f32 = PBC_MIN_WATER_O_O_DIST * PBC_MIN_WATER_O_O_DIST;
60
61// Higher is more accurate, but slower. After hydrogen bond networks are settled, higher doensn't
62// improve things. Note that we initialize from a pre-equilibrated template, so we shouldn't
63// need many effects. This mainly deals with template tiling effects, and solvent-solute conflicts.
64const NUM_EQUILIBRATION_STEPS_WATER: usize = 200;
65const NUM_EQUILIBRATION_STEPS_OTHER_SOLVENT: usize = 600;
66
67// Like in our normal setup with constraint H, 0.002ps may be the safe upper bound.
68// We seem to get better settling results with a low dt.
69const DT_EQUILIBRATION: f32 = 0.0005;
70
71// We generate and use this externally, for example, when passing it to GROMACS in Molchanica.
72pub const WATER_TEMPLATE_60A: &[u8] =
73    include_bytes!("../../param_data/water_60A.water_init_template");
74
75// Included with GROMACS. 4-point water model. 30Å per side?
76pub const WATER_TEMPLATE_TIP4: &str = include_str!("../../param_data/tip4p.gro");
77// We generated this using a shrinking box.
78pub const OCTANOL_WATER_TEMPLATE: &str =
79    include_str!("../../param_data/octanol_water_saturated.gro");
80
81/// Contains variants of templates we have built into this library. These are
82/// included in the binary of applications which use this.
83#[derive(Clone, Debug, PartialEq, Default, Decode, Encode)]
84pub enum SolventTemplateType {
85    Water60A,
86    /// Also usable for any other 4-pt water model. Note that currently we discard the M
87    /// site, adding it manually; consider using the TIP3 template instead, as it's slightly smaller.
88    #[default]
89    Tip4Gromacs,
90    /// Octanol saturated with water at 300C and 1 bar.
91    /// 46Å per side box. 356 octanol mols, 135 water mols.
92    OctanolWithWater,
93    Custom(WaterInitTemplate),
94}
95
96impl SolventTemplateType {
97    pub fn get_template(&self) -> io::Result<WaterInitTemplate> {
98        match self {
99            Self::Water60A => load_from_bytes_bincode(WATER_TEMPLATE_60A),
100            Self::Tip4Gromacs => WaterInitTemplate::from_gro(WATER_TEMPLATE_TIP4),
101            // Currently this method is only for WaterInitTemplate; it's not general-purpose.
102            Self::OctanolWithWater => Ok(Default::default()),
103            Self::Custom(t) => Ok(t.clone()),
104        }
105    }
106}
107
108/// For 3 and 4 point water models.
109///
110/// We store pre-equilibrated solvent molecules in a template, and use it to initialize solvent for a simulation.
111/// This keeps the equilibration steps relatively low. Note that edge effects from tiling will require
112/// equilibration, as well as adjusting a template for the runtime temperature target.
113///
114/// Struct-of-array layout. (Corresponding indices)
115/// Public so it can be created by the application after a run.
116///
117/// 108 bytes/mol. Size on disk/mem: for a 60Å side len: ~780kb. (Hmm: We're getting a bit less)
118/// 80Å/side: 1.20Mb.
119///
120/// M/EP positions are not included: They can be inferred after.
121#[derive(Clone, Debug, PartialEq, Default, Encode, Decode)]
122pub struct WaterInitTemplate {
123    // velocity is o velocity, instead of 3 separate velocities
124    o_posits: Vec<Vec3>,
125    h0_posits: Vec<Vec3>,
126    h1_posits: Vec<Vec3>,
127    o_velocities: Vec<Vec3>,
128    h0_velocities: Vec<Vec3>,
129    h1_velocities: Vec<Vec3>,
130    /// One corner; the opposite. This must correspond to the positions.
131    cell: SimBox,
132}
133
134impl WaterInitTemplate {
135    /// Load a previously-saved template from a file path. Currently, this saves using bincode.
136    /// todo: Make it save as a .gro as well.
137    pub fn load(path: &Path) -> io::Result<Self> {
138        let bytes = fs::read(path)?;
139        load_from_bytes_bincode(&bytes)
140    }
141
142    pub fn from_bytes(bytes: &[u8]) -> io::Result<Self> {
143        load_from_bytes_bincode(bytes)
144    }
145
146    pub fn from_parts(
147        o_posits: Vec<Vec3>,
148        h0_posits: Vec<Vec3>,
149        h1_posits: Vec<Vec3>,
150        o_velocities: Vec<Vec3>,
151        h0_velocities: Vec<Vec3>,
152        h1_velocities: Vec<Vec3>,
153        cell: SimBox,
154    ) -> io::Result<Self> {
155        let n = o_posits.len();
156        let lengths = [
157            h0_posits.len(),
158            h1_posits.len(),
159            o_velocities.len(),
160            h0_velocities.len(),
161            h1_velocities.len(),
162        ];
163
164        if lengths.iter().any(|len| *len != n) {
165            return Err(io::Error::new(
166                io::ErrorKind::InvalidInput,
167                "WaterInitTemplate component lengths must match.",
168            ));
169        }
170
171        Ok(Self {
172            o_posits,
173            h0_posits,
174            h1_posits,
175            o_velocities,
176            h0_velocities,
177            h1_velocities,
178            cell,
179        })
180    }
181
182    pub fn len(&self) -> usize {
183        self.o_posits.len()
184    }
185
186    pub fn is_empty(&self) -> bool {
187        self.o_posits.is_empty()
188    }
189
190    pub fn from_gro(gro_text: &str) -> io::Result<Self> {
191        const NM_TO_ANGSTROM: f32 = 10.;
192
193        let gro = Gro::new(gro_text)?;
194
195        let mut o_posits = Vec::new();
196        let mut h0_posits = Vec::new();
197        let mut h1_posits = Vec::new();
198
199        let mut o_velocities = Vec::new();
200        let mut h0_velocities = Vec::new();
201        let mut h1_velocities = Vec::new();
202
203        for atom in gro.atoms {
204            match atom.atom_type.as_ref() {
205                "OW" => {
206                    let p: Vec3 = atom.posit.into();
207                    o_posits.push(p * NM_TO_ANGSTROM);
208                    let Some(vel) = &atom.velocity else {
209                        return Err(io::Error::other("Missing velocity on tip4 water template"));
210                    };
211                    let v: Vec3 = (*vel).into();
212                    o_velocities.push(v * NM_TO_ANGSTROM.into());
213                }
214                "HW1" => {
215                    let p: Vec3 = atom.posit.into();
216                    h0_posits.push(p * NM_TO_ANGSTROM);
217                    let Some(vel) = &atom.velocity else {
218                        return Err(io::Error::other("Missing velocity on tip4 water template"));
219                    };
220                    let v: Vec3 = (*vel).into();
221                    h0_velocities.push(v * NM_TO_ANGSTROM);
222                }
223                "HW2" => {
224                    let p: Vec3 = atom.posit.into();
225                    h1_posits.push(p * NM_TO_ANGSTROM);
226                    let Some(vel) = &atom.velocity else {
227                        return Err(io::Error::other("Missing velocity on tip4 water template"));
228                    };
229                    let v: Vec3 = (*vel).into();
230                    h1_velocities.push(v * NM_TO_ANGSTROM);
231                }
232                _ => (),
233            }
234        }
235
236        Ok(Self {
237            o_posits,
238            h0_posits,
239            h1_posits,
240            o_velocities,
241            h0_velocities,
242            h1_velocities,
243            cell: SimBox::new(
244                (-gro.box_vec * NM_TO_ANGSTROM as f64 / 2.).into(),
245                (gro.box_vec * NM_TO_ANGSTROM as f64 / 2.).into(),
246            ), // todo: Is this right??
247        })
248    }
249
250    /// Construct from the current state, and save to file.
251    /// Call this explicitly. (todo: Determine a formal or informal approach)
252    pub fn create_and_save(water: &[WaterMolOpc], cell: SimBox, path: &Path) -> io::Result<()> {
253        let n = water.len();
254
255        let mut o_posits = Vec::with_capacity(n);
256        let mut h0_posits = Vec::with_capacity(n);
257        let mut h1_posits = Vec::with_capacity(n);
258
259        let mut o_velocities = Vec::with_capacity(n);
260        let mut h0_velocities = Vec::with_capacity(n);
261        let mut h1_velocities = Vec::with_capacity(n);
262
263        // let (mut min, mut max) = (Vec3::splat(f32::INFINITY), Vec3::splat(f32::NEG_INFINITY));
264
265        // Sort solvent by position so that it iterates out from the center. This makes initialization
266        // easier for cases where this template is larger than the target sim box.
267        let water = {
268            let ctr = cell.center();
269
270            let mut w = water.to_vec();
271            w.sort_by(|a, b| {
272                let da = (a.o.posit - ctr).magnitude_squared();
273                let db = (b.o.posit - ctr).magnitude_squared();
274                da.total_cmp(&db)
275            });
276
277            w
278        };
279
280        for mol in water {
281            o_posits.push(mol.o.posit);
282            h0_posits.push(mol.h0.posit);
283            h1_posits.push(mol.h1.posit);
284
285            o_velocities.push(mol.o.vel);
286            h0_velocities.push(mol.h0.vel);
287            h1_velocities.push(mol.h1.vel);
288
289            // min = min.min(mol.o.posit);
290            // max = max.max(mol.o.posit);
291        }
292
293        let result = Self {
294            o_posits,
295            h0_posits,
296            h1_posits,
297            o_velocities,
298            h0_velocities,
299            h1_velocities,
300            cell,
301        };
302
303        save(path, &result)
304    }
305
306    pub fn from_water_mols(water: &[WaterMolOpc], cell: SimBox) -> io::Result<Self> {
307        let n = water.len();
308
309        let mut o_posits = Vec::with_capacity(n);
310        let mut h0_posits = Vec::with_capacity(n);
311        let mut h1_posits = Vec::with_capacity(n);
312
313        let mut o_velocities = Vec::with_capacity(n);
314        let mut h0_velocities = Vec::with_capacity(n);
315        let mut h1_velocities = Vec::with_capacity(n);
316
317        for mol in water {
318            o_posits.push(mol.o.posit);
319            h0_posits.push(mol.h0.posit);
320            h1_posits.push(mol.h1.posit);
321
322            o_velocities.push(mol.o.vel);
323            h0_velocities.push(mol.h0.vel);
324            h1_velocities.push(mol.h1.vel);
325        }
326
327        Self::from_parts(
328            o_posits,
329            h0_posits,
330            h1_posits,
331            o_velocities,
332            h0_velocities,
333            h1_velocities,
334            cell,
335        )
336    }
337
338    // todo: Identical structs; we could consolidate.
339    /// Note: `gmx solvate`` handles tiling, centering, and solute deconfliction; we can
340    /// send it the raw template.
341    pub fn to_gromacs(&self) -> gromacs::solvate::WaterInitTemplate {
342        gromacs::solvate::WaterInitTemplate {
343            o_posits: self.o_posits.clone(),
344            h0_posits: self.h0_posits.clone(),
345            h1_posits: self.h1_posits.clone(),
346            o_velocities: self.o_velocities.clone(),
347            h0_velocities: self.h0_velocities.clone(),
348            h1_velocities: self.h1_velocities.clone(),
349            bounds: (self.cell.bounds_low, self.cell.bounds_high),
350        }
351    }
352}
353
354/// Determine the number of solvent molecules to add, based on box size and solute.
355pub(in crate::solvent) fn n_water_mols(cell: &SimBox, solute_atoms: &[AtomDynamics]) -> usize {
356    let cell_volume = cell.volume();
357    let mol_volume = sa_surface::vol_take_up_by_atoms(solute_atoms);
358    let free_vol = cell_volume - mol_volume;
359
360    let dims = format!(
361        "{}:.2 x {:.2} x {:.2}",
362        (cell.bounds_high.x - cell.bounds_low.x).abs(),
363        (cell.bounds_high.y - cell.bounds_low.y).abs(),
364        (cell.bounds_high.z - cell.bounds_low.z).abs()
365    );
366
367    println!(
368        "Solvent-free vol: {:.2} Cell vol: {:.2} (ų / 1,000). Dims: {dims} Å",
369        free_vol / 1_000.,
370        cell_volume / 1_000.
371    );
372
373    // Estimate free volume & n_mols from it
374    (WATER_MOLS_PER_VOL * free_vol).round() as usize
375}
376
377/// Create solvent molecules from a template, tiling it as many times as needed to fill the cell.
378/// Works for any cell size: smaller than, equal to, or larger than the template. Deconflcits with
379/// solute molecules, and adds the proper amount based on the free volume (Volume of the cell not
380/// taken up by solute).
381///
382/// The template is always centered on the cell. For cells smaller than the template only tile
383/// (0,0,0) contributes; for larger cells neighboring tiles fill in the rest.
384/// Water-solvent conflict detection uses min-image distances so molecules are never placed too
385/// close to a PBC image of an already-placed molecule.
386///
387/// `template_override`: if provided, use this template instead of the built-in 60 Å water one.
388pub fn water_mols_from_template(
389    cell: &SimBox,
390    // The solute is used for deconfliction and default molecule-count estimation.
391    solute: &[AtomDynamics],
392    specify_num_water: Option<usize>,
393    template_type: &SolventTemplateType,
394    // When true, skip the PBC-boundary proximity check (the 2.8 Å same-tile cross-boundary
395    // filter) entirely.  Only the hard-overlap 1.7 Å direct/PBC-distance check remains.
396    // Use when generating a template at the correct equilibrium density: same-tile boundary
397    // molecules that the filter would reject are acceptable starting points; the MD
398    // equilibration run will push them to their natural first-shell distances.
399    skip_pbc_filter: bool,
400) -> Vec<WaterMolOpc> {
401    match water_mols_from_template_in_region(
402        cell,
403        cell,
404        solute,
405        specify_num_water,
406        template_type,
407        skip_pbc_filter,
408    ) {
409        Ok(water) => water,
410        Err(e) => {
411            eprintln!("\nError initializing water: {e}");
412            Vec::new()
413        }
414    }
415}
416
417fn validate_positive_cell(label: &str, cell: &SimBox) -> io::Result<()> {
418    if cell.extent.x.is_finite()
419        && cell.extent.y.is_finite()
420        && cell.extent.z.is_finite()
421        && cell.extent.x > 0.0
422        && cell.extent.y > 0.0
423        && cell.extent.z > 0.0
424    {
425        Ok(())
426    } else {
427        Err(io::Error::new(
428            io::ErrorKind::InvalidInput,
429            format!(
430                "{label} must have positive finite dimensions; got low={:?}, high={:?}.",
431                cell.bounds_low, cell.bounds_high
432            ),
433        ))
434    }
435}
436
437/// Create water molecules from a template inside a rectangular sub-region of a
438/// larger simulation cell.
439///
440/// `region` and `cell` are in the same coordinate frame. Molecules are accepted
441/// only when their O/H sites are inside `region`, while solute and water-water
442/// conflict checks use `cell` for minimum-image distances. This is useful for
443/// slabs and other partial-cell solvent layouts where bulk solvation would fill
444/// the whole simulation box.
445pub fn water_mols_from_template_in_region(
446    cell: &SimBox,
447    region: &SimBox,
448    solute: &[AtomDynamics],
449    specify_num_water: Option<usize>,
450    template_type: &SolventTemplateType,
451    skip_pbc_filter: bool,
452) -> io::Result<Vec<WaterMolOpc>> {
453    water_mols_from_template_in_region_avoiding(
454        cell,
455        region,
456        solute,
457        &[],
458        specify_num_water,
459        template_type,
460        skip_pbc_filter,
461    )
462}
463
464#[derive(Clone, Copy)]
465struct WaterTemplateCandidate {
466    tile: (i32, i32, i32),
467    o_posit: Vec3,
468    h0_posit: Vec3,
469    h1_posit: Vec3,
470    o_velocity: Vec3,
471    h0_velocity: Vec3,
472    h1_velocity: Vec3,
473}
474
475fn candidate_fits_region(
476    candidate: &WaterTemplateCandidate,
477    cell: &SimBox,
478    region: &SimBox,
479) -> bool {
480    [candidate.o_posit, candidate.h0_posit, candidate.h1_posit]
481        .into_iter()
482        .all(|posit| region.contains(posit) && cell.contains(posit))
483}
484
485fn spatially_interleave_candidates(
486    candidates: Vec<WaterTemplateCandidate>,
487    region: &SimBox,
488    desired_count: usize,
489) -> Vec<WaterTemplateCandidate> {
490    let bins_per_axis = (desired_count as f32).cbrt().ceil() as usize;
491    let mut bins = vec![Vec::new(); bins_per_axis.pow(3)];
492    let bin_coord = |value: f32, low: f32, extent: f32| {
493        (((value - low) / extent * bins_per_axis as f32).floor() as usize).min(bins_per_axis - 1)
494    };
495
496    for candidate in candidates {
497        let x = bin_coord(candidate.o_posit.x, region.bounds_low.x, region.extent.x);
498        let y = bin_coord(candidate.o_posit.y, region.bounds_low.y, region.extent.y);
499        let z = bin_coord(candidate.o_posit.z, region.bounds_low.z, region.extent.z);
500        bins[x + bins_per_axis * (y + bins_per_axis * z)].push(candidate);
501    }
502
503    let max_bin_len = bins.iter().map(Vec::len).max().unwrap_or(0);
504    let mut bin_order: Vec<_> = (0..bins.len()).collect();
505    bin_order.sort_unstable_by_key(|index| (*index as u32).wrapping_mul(0x9E37_79B9));
506    let mut result = Vec::new();
507
508    for round in 0..max_bin_len {
509        for &bin_i in &bin_order {
510            let bin = &bins[bin_i];
511            if let Some(candidate) = bin.get(round) {
512                result.push(*candidate);
513            }
514        }
515    }
516
517    result
518}
519
520pub(crate) fn water_mols_from_template_in_region_avoiding(
521    cell: &SimBox,
522    region: &SimBox,
523    solute: &[AtomDynamics],
524    prior_water: &[WaterMolOpc],
525    specify_num_water: Option<usize>,
526    template_type: &SolventTemplateType,
527    skip_pbc_filter: bool,
528) -> io::Result<Vec<WaterMolOpc>> {
529    validate_positive_cell("Simulation cell", cell)?;
530    validate_positive_cell("Water placement region", region)?;
531
532    if !cell.contains(region.bounds_low) || !cell.contains(region.bounds_high) {
533        return Err(io::Error::new(
534            io::ErrorKind::InvalidInput,
535            "Water placement region must be fully inside the simulation cell.",
536        ));
537    }
538
539    println!("Initializing solvent molecules...");
540    let start = Instant::now();
541
542    let template = template_type.get_template()?;
543    validate_positive_cell("Water template cell", &template.cell)?;
544
545    let solute_for_count: Vec<_> = if cell == region {
546        Vec::new()
547    } else {
548        solute
549            .iter()
550            .filter(|atom| region.contains(atom.posit))
551            .cloned()
552            .collect()
553    };
554    let n_mols = specify_num_water.unwrap_or_else(|| {
555        if cell == region {
556            n_water_mols(region, solute)
557        } else {
558            n_water_mols(region, &solute_for_count)
559        }
560    });
561    let mut result = Vec::with_capacity(n_mols);
562
563    if n_mols == 0 {
564        println!("Complete in {} ms.", start.elapsed().as_millis());
565        return Ok(result);
566    }
567
568    let solute_posits: Vec<_> = solute.iter().map(|a| a.posit).collect();
569
570    let template_size = template.cell.extent;
571    let template_ctr = template.cell.center();
572
573    let region_ctr = region.center();
574
575    // Align tile (0,0,0) center to the placement region center.
576    let base_offset = region_ctr - template_ctr;
577
578    // Number of half-tiles needed to cover the cell in each direction (+1 for safety).
579    let region_size = region.extent;
580    let half_x = (region_size.x / (2.0 * template_size.x)).ceil() as i32 + 1;
581    let half_y = (region_size.y / (2.0 * template_size.y)).ceil() as i32 + 1;
582    let half_z = (region_size.z / (2.0 * template_size.z)).ceil() as i32 + 1;
583
584    let mut loops_used = 0;
585
586    // Parallel vecs: tile index of each molecule placed in `result`.
587    // Used to restrict the PBC soft filter to same-tile pairs (see comment below).
588    let mut placed_tiles: Vec<(i32, i32, i32)> = Vec::with_capacity(n_mols);
589
590    let make_candidate =
591        |tile: (i32, i32, i32), tile_offset: Vec3, i: usize| WaterTemplateCandidate {
592            tile,
593            o_posit: template.o_posits[i] + tile_offset,
594            h0_posit: template.h0_posits[i] + tile_offset,
595            h1_posit: template.h1_posits[i] + tile_offset,
596            o_velocity: template.o_velocities[i],
597            h0_velocity: template.h0_velocities[i],
598            h1_velocity: template.h1_velocities[i],
599        };
600
601    let mut place_candidate = |candidate: WaterTemplateCandidate| {
602        if !candidate_fits_region(&candidate, cell, region) {
603            return false;
604        }
605
606        // Conflict with solute atoms.
607        for &atom_p in &solute_posits {
608            if cell
609                .min_image(atom_p - candidate.o_posit)
610                .magnitude_squared()
611                < MIN_NONWATER_DIST_SQ
612            {
613                return false;
614            }
615        }
616
617        // Regions are populated independently, so their template phases may not line
618        // up. Keep new molecules outside the first-shell distance of waters accepted
619        // for earlier regions to avoid artificial high-energy contacts at boundaries.
620        for w in prior_water {
621            let diff = cell.min_image(w.o.posit - candidate.o_posit);
622            if diff.magnitude_squared() < PBC_MIN_WATER_O_O_DIST_SQ {
623                return false;
624            }
625        }
626
627        // Conflict with already-placed solvent.
628        //
629        // Hard overlap (1.7 Å): always reject, regardless of PBC or tile.
630        //
631        // PBC soft filter (2.8 Å): only applies to SAME-TILE pairs.
632        //   - Large-template (e.g. Water60A, 60 Å) in a small cell: only one tile
633        //     contributes, so same-tile molecules from opposite ends of the template
634        //     can land at unequilibrated PBC distances of 2-3 Å. The filter rejects
635        //     them.
636        //   - Small-template (e.g. tip4p, 18.68 Å) in a large cell: adjacent tiles
637        //     tile the cell; cross-tile molecules near the cell boundary are legitimate
638        //     PBC neighbours equilibrated in the template. Applying the filter to
639        //     cross-tile pairs incorrectly rejects about 10 percent of molecules.
640        for (j, w) in result.iter().enumerate() {
641            let diff = w.o.posit - candidate.o_posit;
642            let direct_sq = diff.magnitude_squared();
643            if direct_sq < MIN_WATER_O_O_DIST_SQ {
644                return false;
645            }
646            let min_image_sq = cell.min_image(diff).magnitude_squared();
647            // Always reject PBC hard overlaps (PBC distance < 1.7 Å) even when
648            // skip_pbc_filter is true, to prevent catastrophic initial forces.
649            if min_image_sq < MIN_WATER_O_O_DIST_SQ {
650                return false;
651            }
652            if !skip_pbc_filter && placed_tiles[j] == candidate.tile {
653                if min_image_sq < PBC_MIN_WATER_O_O_DIST_SQ && min_image_sq < direct_sq {
654                    return false;
655                }
656            }
657        }
658
659        let mut mol = WaterMolOpc::new(
660            Vec3::new_zero(),
661            Vec3::new_zero(),
662            Quaternion::new_identity(),
663        );
664
665        // todo: I'm not sure how we're handling the M/EP point. I guess it's placed
666        // todo: automatically during integration.
667
668        mol.o.posit = candidate.o_posit;
669        mol.h0.posit = candidate.h0_posit;
670        mol.h1.posit = candidate.h1_posit;
671
672        mol.o.vel = candidate.o_velocity;
673        mol.h0.vel = candidate.h0_velocity;
674        mol.h1.vel = candidate.h1_velocity;
675        mol.update_virtual_site();
676
677        result.push(mol);
678        placed_tiles.push(candidate.tile);
679        result.len() == n_mols
680    };
681
682    if specify_num_water.is_some() {
683        let mut candidates = Vec::new();
684
685        for ix in -half_x..=half_x {
686            for iy in -half_y..=half_y {
687                for iz in -half_z..=half_z {
688                    let tile = (ix, iy, iz);
689                    let tile_offset = base_offset
690                        + Vec3::new(
691                            ix as f32 * template_size.x,
692                            iy as f32 * template_size.y,
693                            iz as f32 * template_size.z,
694                        );
695
696                    for i in 0..template.o_posits.len() {
697                        loops_used += 1;
698                        let candidate = make_candidate(tile, tile_offset, i);
699                        if candidate_fits_region(&candidate, cell, region) {
700                            candidates.push(candidate);
701                        }
702                    }
703                }
704            }
705        }
706
707        for candidate in spatially_interleave_candidates(candidates, region, n_mols) {
708            if place_candidate(candidate) {
709                break;
710            }
711        }
712    } else {
713        'tiles: for ix in -half_x..=half_x {
714            for iy in -half_y..=half_y {
715                for iz in -half_z..=half_z {
716                    let tile = (ix, iy, iz);
717                    let tile_offset = base_offset
718                        + Vec3::new(
719                            ix as f32 * template_size.x,
720                            iy as f32 * template_size.y,
721                            iz as f32 * template_size.z,
722                        );
723
724                    for i in 0..template.o_posits.len() {
725                        loops_used += 1;
726                        if place_candidate(make_candidate(tile, tile_offset, i)) {
727                            break 'tiles;
728                        }
729                    }
730                }
731            }
732        }
733    }
734
735    let elapsed = start.elapsed().as_millis();
736    println!(
737        "Added {} / {n_mols} solvent mols in {elapsed} ms. Used {loops_used} loops",
738        result.len()
739    );
740
741    Ok(result)
742}
743
744impl MdState {
745    fn mark_solute_static_for_init_relaxation(&mut self) -> Vec<bool> {
746        let mut static_state = Vec::with_capacity(self.atoms.len());
747
748        for (i, atom) in self.atoms.iter_mut().enumerate() {
749            static_state.push(atom.static_);
750            if i < self.solute_atom_count {
751                atom.static_ = true;
752            }
753        }
754
755        static_state
756    }
757
758    fn restore_static_state(&mut self, static_state: &[bool]) {
759        for (atom, &was_static) in self.atoms.iter_mut().zip(static_state.iter()) {
760            atom.static_ = was_static;
761        }
762    }
763
764    /// Use this to help initialize solvent molecules to realistic geometry of hydrogen bond networks,
765    /// prior to the first proper simulation step. Runs MD on solvent only.
766    /// Make sure to only run this after state is properly initialized, e.g. towards the end
767    /// of init; not immediately after populating waters.
768    ///
769    /// This will result in an immediate energy bump as solvent positions settle from their grid
770    /// into position. As they settle, the thermostat will bring the velocities down to set
771    /// the target temp. This sim should run long enough to the solvent is stable by the time
772    /// the main sim starts.
773    pub fn md_on_solute_only(&mut self, dev: &ComputationDevice) {
774        println!("Initializing solvent structure prior to production MD...");
775        let start = Instant::now();
776
777        // This disables things like snapshot saving, and certain prints.
778        self.solvent_only_sim_at_init = true;
779        let thermo_dof_prev = self.thermo_dof;
780
781        // Freeze the solute atoms while leaving explicit solvent atoms in `self.atoms`
782        // free to relax alongside rigid OPC water.
783        let static_state = self.mark_solute_static_for_init_relaxation();
784        self.thermo_dof = self.dof_for_thermo();
785
786        let steps = match self.cfg.solvent {
787            Solvent::None => 0,
788            Solvent::WaterOpc
789            | Solvent::WaterOpcSpecifyMolCount(_)
790            | Solvent::WaterOpcCustomRegions(_) => NUM_EQUILIBRATION_STEPS_WATER,
791            Solvent::OctanolWithWater | Solvent::Custom(_) => NUM_EQUILIBRATION_STEPS_OTHER_SOLVENT,
792        };
793
794        for _ in 0..steps {
795            self.step(dev, DT_EQUILIBRATION, None);
796        }
797
798        self.restore_static_state(&static_state);
799        self.solvent_only_sim_at_init = false;
800        self.thermo_dof = thermo_dof_prev;
801        self.step_count = 0; // Reset.
802
803        let elapsed = start.elapsed().as_millis();
804        println!("Solvent initialization MD complete in {elapsed} ms");
805    }
806}