Source code for cemd.build.solution

#
# This file is part of the CEMD distribution
# Copyright (c) 2022-2026 Jérôme Claverie.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

from __future__ import annotations

import tempfile
import warnings
from collections.abc import Sequence
from dataclasses import dataclass, field
from functools import cache
from typing import TYPE_CHECKING

import numpy as np

from .._constants import AVOGADRO, MASSES_DICT
from ._packmol import (
    PackmolInput,
    PackmolStructure,
    get_structure_path,
    run_packmol,
)
from .base import require_program

if TYPE_CHECKING:
    from ..core.atomic_system import AtomicSystem

WATER_MOLAR_MASS: float = 2 * MASSES_DICT["H"] + MASSES_DICT["O"]

# Extensions probed by `_packmol._resolve_structure`, in its own order.
_BUNDLED_EXTENSIONS: tuple[str, ...] = (".lt", ".pdb", ".sdf")


def _bundled_structure_names() -> set[str]:
    """Names of the polyatomic species shipped with CEMD."""
    from ._structures import STRUCTURES_DIR

    return {
        path.stem.upper()
        for extension in _BUNDLED_EXTENSIONS
        for path in STRUCTURES_DIR.glob(f"*{extension}")
    }


@cache
def _bundled_structure_mass(species: str) -> float | None:
    """Total mass of a species shipped with CEMD, or None if it has none."""
    from ..core.atomic_system import AtomicSystem
    from ._structures import STRUCTURES_DIR

    for extension in _BUNDLED_EXTENSIONS:
        path = STRUCTURES_DIR / f"{species.lower()}{extension}"
        if path.exists():
            return float(AtomicSystem.from_file(path).total_mass)

    return None


def _concentration2count(molarity: float | int, volume: float) -> tuple[int, float]:
    """Calculates the integer particle count and relative error for a single molarity.

    Parameters
    ----------
    molarity : float | int
        Target concentration in mol/L (M).
    volume : float
        Volume of the simulation box in cubic Angstroms (ų).

    Returns
    -------
    tuple[int, float]
        -Particle count (integer).
        -Relative discretization error percentage (float).
    """
    if molarity == 0:
        return 0, 0.0

    theoretical_n = molarity * AVOGADRO * volume * 1e-27
    final_count = int(round(theoretical_n))

    if theoretical_n > 0:
        error_pct = abs(final_count - theoretical_n) / theoretical_n
    else:
        error_pct = 0.0

    return final_count, error_pct


[docs] @dataclass class SolutionBuilder: """ Blueprint for creating a solution. This blueprint defines WHAT the solution contains (composition), not HOW it is built (geometry). The geometry is determined by the builder or method that uses this blueprint. Parameters ---------- density : float, default=1.0 Target density in g/cm³ molarities : dict, optional Species molarities in mol/L counts : dict, optional Species explicit counts (number of molecules/ions) structures : dict, optional Custom AtomicSystem structures for complex molecules Examples -------- >>> # Define a salt solution >>> blueprint = SolutionBuilder( ... density=1.0, ... molarities={'NaCl': 0.1, 'KCl': 0.05} ... ) >>> # Use in different contexts >>> solution = blueprint.build(box=[30, 30, 30]) # Standalone >>> system = surface.add_liquid_layer(blueprint, thickness=30.0) # Layer >>> system = surface.add_droplet(blueprint, radius=15.0) # Droplet >>> # Pure water >>> water = SolutionBuilder.from_water() >>> # With explicit counts >>> blueprint = SolutionBuilder( ... density=1.0, ... counts={'Na': 50, 'Cl': 50} ... ) """ density: float = 1.0 molarities: dict[str, float] = field(default_factory=dict) counts: dict[str, int] = field(default_factory=dict) structures: dict[str, AtomicSystem] = field(default_factory=dict) def __post_init__(self): """Validate the blueprint.""" self._validate() def __repr__(self) -> str: """ Return a styled string representation of the SolutionBuilder. Returns ------- str Summary of the solution composition and density. """ lines = ["<SolutionBuilder>"] lines.append("") # ====== Composition ====== lines.append("┌─ Composition") lines.append(f"│ density: {self.density:.2f} g/cm³") # Solutes if self.molarities: lines.append("│ type: molarities") for species, molarity in self.molarities.items(): lines.append(f"│ {species}: {molarity:.3f} M") elif self.counts: lines.append("│ type: counts") for species, count in self.counts.items(): lines.append(f"│ {species}: {count}") else: lines.append("│ type: pure water") # ====== Custom structures ====== if self.structures: lines.append("│") lines.append("├─ Custom structures") for species, struct in self.structures.items(): lines.append(f"│ {species}: {struct.num_atoms} atoms") return "\n".join(lines) def _validate(self): """Check that the blueprint is valid.""" if self.density <= 0: raise ValueError(f"Density must be positive, got {self.density}") # Validate molarities for species, value in self.molarities.items(): if not isinstance(value, (int, float)): raise TypeError( f"Molarity for '{species}' must be a number, " f"got {type(value).__name__}" ) if value < 0: raise ValueError(f"Molarity must be >= 0 for '{species}'") # Validate counts for species, value in self.counts.items(): if not isinstance(value, int): raise TypeError( f"Count for '{species}' must be an integer, " f"got {type(value).__name__}" ) if value < 0: raise ValueError(f"Count must be >= 0 for '{species}'") # Validate custom structures for species, struct in self.structures.items(): from ..core.atomic_system import AtomicSystem if not isinstance(struct, AtomicSystem): raise TypeError( f"Structure for '{species}' must be an AtomicSystem, " f"got {type(struct).__name__}" )
[docs] @classmethod def from_water( cls, density: float = 1.0, ) -> SolutionBuilder: """ Create a blueprint for pure water. Parameters ---------- density : float, default=1.0 Target density in g/cm³ Returns ------- SolutionBuilder A blueprint configured for pure water. Examples -------- >>> water = SolutionBuilder.from_water() >>> solution = water.build(box=[30, 30, 30]) """ return cls( density=density, molarities={}, counts={}, structures={}, )
[docs] def to_counts(self, volume: float) -> dict[str, int]: """ Convert molarities to explicit counts for a given volume and combine them with explicitly specified counts. """ counts = self.counts.copy() for species, molarity in self.molarities.items(): if species in counts: raise ValueError( f"Species '{species}' is specified in both " "`counts` and `molarities`." ) count, error = _concentration2count(molarity, volume) counts[species] = count if error > 0.1: warnings.warn( f"Species '{species}' has {error:.1%} discretization error. " f"Using {count} molecules (target: {molarity} M)." ) return counts
[docs] def get_solute_mass(self, volume: float) -> float: """ Calculate total mass of solutes in atomic mass units for a given volume. Parameters ---------- volume : float Volume in ų Returns ------- float Total solute mass in amu Raises ------ ValueError If a species is not found in the mass database. """ counts = self.to_counts(volume) total_mass = 0.0 for species, count in counts.items(): if species in self.structures: total_mass += count * self.structures[species].total_mass continue # Same resolution order as the packer (`_resolve_structure`): # a bundled file wins over the element table, so that the mass # used here describes whatever actually gets packed. Without # this, the polyatomic species shipped with CEMD -- "HO", # "CO3", "SO4" -- could be packed by name but not weighed, and # had to be passed again through `structures=`. bundled = _bundled_structure_mass(species) if bundled is not None: total_mass += count * bundled elif species in MASSES_DICT: total_mass += count * MASSES_DICT[species] else: raise ValueError( f"Species '{species}' not found in mass database " f"and no custom structure provided.\n" f"Available: {list(MASSES_DICT.keys())} " f"and the bundled structures " f"{sorted(_bundled_structure_names())}" ) return total_mass
[docs] def get_water_count(self, volume: float) -> int: """ Calculate number of water molecules for a given volume. Parameters ---------- volume : float Volume in ų Returns ------- int Number of water molecules Raises ------ ValueError If the density or volume is too low for the solutes. """ mass_total_target_g = self.density * volume * 1e-24 mass_solutes_g = self.get_solute_mass(volume) / AVOGADRO mass_water_g = mass_total_target_g - mass_solutes_g if mass_water_g <= 0: raise ValueError( f"Target density ({self.density} g/cm³) or volume ({volume:.1f} ų) " f"is too low to hold the solutes.\n" f"Solute mass: {mass_solutes_g:.4f} g\n" f"Target total mass: {mass_total_target_g:.4f} g" ) return int(round((mass_water_g * AVOGADRO) / WATER_MOLAR_MASS))
[docs] def build( self, box: Sequence[float], margin: float = 0.95, ) -> AtomicSystem: """ Build a standalone solution system from this blueprint. Parameters ---------- box : Sequence[float] Box dimensions [a, b, c] in Å. margin : float, default=0.95 Safety scaling factor to prevent atoms from sitting directly on box edges. Returns ------- AtomicSystem The built solution system. """ from ..core.atomic_system import AtomicSystem require_program("packmol") box = list(box) boxa, boxb, boxc = box[:3] volume = boxa * boxb * boxc solute_counts = self.to_counts(volume) num_water = self.get_water_count(volume) inside_box = ( 0.0, 0.0, 0.0, boxa * margin, boxb * margin, boxc * margin, ) with tempfile.TemporaryDirectory(dir=".") as tmp: h2o_path = get_structure_path("H2O", tmp) h2o = AtomicSystem.from_file(h2o_path) h2o.set_types({"H1": "Hw", "H2": "Hw", "O1": "Ow"}) structures = [ PackmolStructure( structure=h2o, number=num_water, inside_box=inside_box, ) ] for species, count in solute_counts.items(): if count <= 0: continue structure = self.structures.get(species, species) structures.append( PackmolStructure( structure=structure, number=count, center=True, inside_box=inside_box, ) ) packmol_input = PackmolInput( tolerance=2.0, output="solution.pdb", filetype="pdb", structures=structures, ) data = run_packmol(packmol_input) final_box = box[:3] + [90.0, 90.0, 90.0] if len(box) == 3 else box data.set_box(final_box) data._solution_metadata = { "density": self.density, "solutes": solute_counts, "num_water": num_water, "volume": volume, "blueprint": self, } return data
[docs] def build_hemisphere( self, radius: float, axis: str = "z", ) -> AtomicSystem: """ Build a hemispherical solution system from this blueprint. The hemisphere is centered at the origin, with its flat surface lying in the plane perpendicular to ``axis``. Parameters ---------- radius : float Hemisphere radius in Å. axis : {"x", "y", "z"}, default="z" Axis normal to the flat surface of the hemisphere. Returns ------- AtomicSystem The built hemispherical solution system. Raises ------ ValueError If ``radius`` is not positive or ``axis`` is invalid. """ from ..core.atomic_system import AtomicSystem require_program("packmol") if radius <= 0: raise ValueError("The radius must be positive.") if axis not in {"x", "y", "z"}: raise ValueError(f"Invalid axis: {axis!r}. Expected 'x', 'y', or 'z'.") # Hemisphere volume volume = 2.0 * np.pi * radius**3 / 3.0 # Number of solute molecules solute_counts = self.to_counts(volume) # Number of water molecules num_water = self.get_water_count(volume) # Packmol half-space definition above_plane = { "x": (1.0, 0.0, 0.0, 0.0), "y": (0.0, 1.0, 0.0, 0.0), "z": (0.0, 0.0, 1.0, 0.0), }[axis] inside_sphere = (0.0, 0.0, 0.0, radius) with tempfile.TemporaryDirectory(dir=".") as tmp: h2o_path = get_structure_path("H2O", tmp) h2o = AtomicSystem.from_file(h2o_path) # Match `build()`'s naming convention: downstream topology # rules (e.g. CLAYFF_RULES) and force-field assignment key off # "Ow"/"Hw", not the raw template types "O1"/"H1"/"H2". h2o.set_types({"H1": "Hw", "H2": "Hw", "O1": "Ow"}) structures = [ PackmolStructure( structure=h2o, number=num_water, inside_sphere=inside_sphere, above_plane=above_plane, ) ] # Solutes for species, count in solute_counts.items(): if count <= 0: continue structure = self.structures.get(species, species) structures.append( PackmolStructure( structure=structure, number=count, center=True, inside_sphere=inside_sphere, above_plane=above_plane, ) ) packmol_input = PackmolInput( tolerance=2.0, output="solution_hemisphere.pdb", filetype="pdb", structures=structures, ) data = run_packmol(packmol_input) # Simulation box box_size = 2.5 * radius final_box = [ box_size, box_size, 1.5 * radius, 90.0, 90.0, 90.0, ] data.set_box(final_box) data._solution_metadata = { "density": self.density, "solutes": solute_counts, "num_water": num_water, "volume": volume, "radius": radius, "axis": axis, "blueprint": self, } return data